Skip to content

Repository files navigation

strava-utils

Small scripts for poking at Strava data. Standard library only (no pip install).

Auth

1. Create the API app

Go to https://www.strava.com/settings/api:

Field What to put Why
Application Name anything, e.g. strutils cosmetic, can't include "strava"
Category anything, e.g. Data Importer cosmetic
Club leave empty cosmetic
Website anything valid, e.g. your GitHub URL cosmetic, but must parse as a URL
Application Description leave empty cosmetic
Authorization Callback Domain localhost must be exactly this: no http://, no port, no path, or the redirect fails

Strava then demands an app icon before showing the credentials. Upload any small square image. Client ID and Client Secret (click "Show") appear after that.

2. Run the token helper

python3 get_token.py

Prompts for Client ID and Secret, opens the consent page, catches the redirect on http://localhost:8080, and writes .env (chmod 600, gitignored). The other scripts read it automatically.

Keep both boxes checked on the consent screen: "View data about your private activities" (activity:read_all, without it private activities are silently missing) and "Upload your activities" (activity:write, without it every import_gpx.py upload fails with Authorization Error). The helper warns if either was not granted.

Refresh tokens do not expire, so this is one-time. To skip the refresh flow instead, put STRAVA_ACCESS_TOKEN=... (valid ~6h) in .env or the environment.

find_duplicate_runs.py

Every day with more than one run, flagging pairs that look like the same activity imported twice.

python3 find_duplicate_runs.py --after 2024-01-01
2024-05-12  (2 activities)
  07:02   10.03 km   52 min  Morning Run                              https://www.strava.com/activities/111
  07:02   10.05 km   52 min  Morning Run (imported)                   https://www.strava.com/activities/222
  !! likely duplicate: 111 <-> 222

1 multi-activity days, 1 likely duplicate pairs.

Flagged when distance and moving time both match within 2%. Genuine doubles are printed but not flagged. Nothing is ever deleted.

Flag Meaning
--type Run sport_type filter. Pass --type '' for all types. Default Run.
--after YYYY-MM-DD Only activities after this date.
--json Dump the grouped days as JSON instead of the report.
--no-verify Skip the deleted-activity recheck (fewer requests, stale results).
--demo Run the built-in self-check.

Strava's activity list keeps returning activities for a while after you delete them, so each candidate day is re-checked one activity at a time and anything already gone is dropped.

import_gpx.py

Bulk-import GPX files, one upload at a time.

python3 import_gpx.py ./tracks --sport-type Run
312 file(s) to upload, 0 already done.
[1/312] 2019-03-04-run.gpx: ok https://www.strava.com/activities/111
[2/312] 2019-03-06-run.gpx: duplicate of activity 222
rate limited, sleeping 11.4 min until 14:15 CEST
[3/312] 2019-03-09-run.gpx: ok https://www.strava.com/activities/333

Directories are searched recursively, so a tree like tracks/2019/…/track.gpx works as-is; only .gpx and .gpx.gz are picked up. Each settled file (imported, or rejected as a duplicate) has its full path appended to .imported and is skipped on the next run, so an interrupted import resumes where it stopped and same-named files in different subfolders never shadow each other. Delete .imported to start over.

Flag Meaning
--sport-type Run Run, Ride, Hike, ... Default: whatever Strava reads from the file.
--description ... Applied to every upload.
--no-wait Queue and move on: halves the requests, but duplicates/errors go unseen.
--state PATH Use a different done-list file.
--dry-run Print the files that would be uploaded.
--demo Offline self-check of the multipart encoder and rate-limit math.

The GPX itself supplies the name, dates and track.

merge_activities.py

Merge activities of the same sport into one, in the order you list them.

python3 merge_activities.py 111 222 --dry-run   # report and back up, upload nothing
python3 merge_activities.py 111 222             # ... and publish the merge
Merging 2 activities, in the order given:

  111  2026-09-10 18:35    1.25 km (-1.9% vs its GPS track)    6:17 moving    6:49 elapsed  Evening Run
  222  2026-09-10 18:43    8.92 km (+0.1% vs its GPS track)   40:35 moving   40:43 elapsed  Thursday Evening Run

  distance     10.17 km
  elapsed      47:33   what the merged GPX spans
  moving       46:52   Strava recomputes this from the merged track, so the published
                       activity lands near this, not exactly on it
  seam 1        1:21   of dead time dropped, 0.02 km jumped

  merged GPX merged-111.gpx
  backups    merge-backup-111/ (4 files, restore with `python3 import_gpx.py merge-backup-111/`)

Each source becomes one <trkseg>, re-stamped to start 1s after the previous one ended. That is the whole trick: Strava takes elapsed time from the first and last GPX timestamp, so keeping the real clock would bill the dead time between activities to the merge.

Reading the numbers

  • elapsed is exact — the merged GPX spans it by construction.
  • moving is not. Strava recomputes it from the merged track, and the 1s seam reads as a fast jump, so the published activity comes out a few seconds above the sum. (Real example: 46:52 predicted, 47:33 elapsed, 47:20 published.)
  • % vs its GPS track compares Strava's own distance against the points it gave back. A few tenths is normal; a large drift means the streams came back wrong — do not publish.
  • A negative seam means those two activities overlap: check the order you passed.

Publishing, and why it needs you

The merge starts at the same instant as its first source, so Strava refuses it while that source exists — and DELETE /activities is gone from the API, answering 401 Unauthorized whatever the token's scope, because deletion is a partner-only permission. No script can do it.

So the run pauses: it prints the source URLs, you delete them in the browser (··· → Delete), press Enter, and it re-fetches each one to confirm it 404s before retrying the upload. q aborts.

Both files are written before any of that, and the backup folder holds only the sources — each as its detail JSON plus a GPX on its original timestamps — so python3 import_gpx.py merge-backup-111/ puts back exactly what you deleted, and nothing else. It is never cleaned up automatically.

Indoor activities have no GPS stream and cannot be merged. Heart rate, cadence and power are not carried over — the merged activity is a GPS track only. --demo runs the offline self-check.

strava.py

Not a script. Shared plumbing the others import: .env loading, access_token() (refresh flow), request_json(), rate_limit_reset(), fetch_activities(), and the API / TOKEN_URL / ENV_FILE constants.

Limits

Default limits are 100 requests / 15 min and 1,000 / day. On a 429, request_json() reads X-RateLimit-Usage and sleeps until the 15-minute window resets (aligned to :00 :15 :30 :45), up to 10 waits. If the daily limit is what ran out, it exits and prints the local datetime to retry after — re-running import_gpx.py then picks up from .imported.

An import costs 2 requests per file (upload + one status poll, more if Strava is slow to process), so roughly 45 files per 15-minute window and ~450 per day. 300+ files means a few sleeps but one run; --no-wait doubles the throughput at the cost of not seeing failures.

Strava subscribers (premium) can raise their app's limits to 400 requests / 15 min and 4,000 / day — request the increase from the app's page at https://www.strava.com/settings/api (or via https://developers.strava.com/docs/rate-limits/). That takes the same import to ~200 files per window, i.e. one pass with a single sleep. Nothing in these scripts needs changing: the backoff reads the actual limits off the response headers.

A merge costs 2 requests per source activity (detail + streams) plus 2 for the upload.

Nothing here can delete anything: Strava dropped DELETE /activities from the API, so deletions are always yours to make in the browser. merge_activities.py backs the activities up first and waits for you.

About

A small collection of utilities built around the Strava API for managing and maintaining activity data.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages