Skip to content

Latest commit

 

History

13 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

strava-utils

Small scripts for poking at Strava data.

Quick start

Needs uv.

make run     # the TUI launcher
make test    # the suite in tests/

The launcher builds each form from the script's own --flags, so there is no second list to keep in sync. Run stays disabled until the required fields are filled, and the exact command is echoed so you can copy it.

╭──────────────────────╮╭────────────────────────────────────────────────────╮
│ export_runs          ││ Export Strava activities as a CSV                  │
│ find_duplicate_runs  ││                                                    │
│ import_gpx           ││ --after *        2024-01-01                        │
│ merge_activities     ││ --before         last day to export, YYYY-MM-DD    │
│ get_token            ││ --type           Run                               │
│                      ││ --out            write here instead of stdout      │
│                      │╰────────────────────────────────────────────────────╯
│                      │╭────────────────────────────────────────────────────╮
│                      ││ $ python3 -m strava_utils.export_runs --after ...  │
│                      ││ fetched 200 activities...                          │
╰──────────────────────╯╰────────────────────────────────────────────────────╯
 q Quit   r Run                                                   [ Run ]

get_token and merge_activities prompt on stdin, so the launcher suspends and hands them the real terminal.

Any script also runs alone, with no virtualenv and nothing installed:

python3 -m strava_utils.export_runs --after 2026-09-01 > runs.csv
python3 -m strava_utils.<name> --help

Auth

Create an app at https://www.strava.com/settings/api. Everything there is cosmetic except Authorization Callback Domain, which must be exactly localhost (no http://), no port, no path, or the redirect fails. Strava demands an app icon before it shows the credentials.

python3 -m strava_utils.get_token

Prompts for Client ID and Secret, opens the consent page, catches the redirect on http://localhost:8080, and writes .env (chmod 600, gitignored). Every script reads it.

Keep both consent boxes checked: activity:read_all (or private activities go silently missing) and activity:write (or every upload fails with Authorization Error). Refresh tokens do not expire, so this is one-time. To skip the refresh flow, put STRAVA_ACCESS_TOKEN=... (valid ~6h) in .env instead.

find_duplicate_runs

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

python3 -m strava_utils.find_duplicate_runs --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

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. --type '' for all. 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).

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.

export_runs

A CSV of your runs, ready to paste into a spreadsheet.

python3 -m strava_utils.export_runs --after 2026-09-01 --before 2026-09-10 > runs.csv
Date,Day,Distance,Time,Shoe
01/09/26,Tue,5.00,00:21:28,
05/09/26,Sat,15.00,01:16:15,Adidas Adizero Evo SL

Distance in km, Time is moving time, Shoe blank when no gear is assigned.

Flag Meaning
--after YYYY-MM-DD First day. Required, inclusive.
--before YYYY-MM-DD Last day, inclusive. Default: today.
--type Run sport_type filter. --type '' for all. Default Run.
--out runs.csv Write to a file instead of stdout.

Both dates compare against start_date_local, your own clock, so a day means the day you ran, not a UTC window.

import_gpx

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

python3 -m strava_utils.import_gpx ./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

Directories are searched recursively; 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 next run; so an interrupted import resumes, 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.

The GPX itself supplies the name, dates and track.

merge_activities

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

python3 -m strava_utils.merge_activities 111 222 --dry-run   # report and back up only
python3 -m strava_utils.merge_activities 111 222             # ... and publish
  111  2026-09-10 18:35   1.25 km (-1.9% vs its GPS track)   6:17 moving  Evening Run
  222  2026-09-10 18:43   8.92 km (+0.1% vs its GPS track)  40:35 moving  Thursday Evening Run

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

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 by construction. moving is not. Strava recomputes it and the 1s seam reads as a fast jump, so the published activity comes out a few seconds above the sum. % 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, so do not publish. A negative seam means those two activities overlap; check the order.

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 whatever the token's scope, because deletion is partner-only. No script can do it. So the run pauses: it prints the source URLs, you delete them in the browser, press Enter, and it re-fetches each to confirm it 404s before retrying. q aborts.

Both the merged GPX and the backups 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 -m strava_utils.import_gpx merge-backup-111/ puts back exactly what you deleted. 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.

Layout

strava_utils/
  strava.py    shared: .env loading, access_token(), request_json(), rate limits, fetch_activities()
  tui.py       the launcher
  *.py         one script each, exposing build_parser() so the launcher can read its flags
tests/         make test

.env and .imported live at the repo root (strava.ROOT), one level above the package. Every script also carries an offline demo() self-check: python3 -m strava_utils.<name> --demo.

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, up to 10 waits. If the daily limit ran out it exits and prints when to retry, re-running import_gpx picks up from .imported.

An import costs 2 requests per file, so roughly 45 files per 15-minute window and ~450 per day; --no-wait doubles that at the cost of not seeing failures. A merge costs 2 requests per source activity plus 2 for the upload.

Strava subscribers can raise their app's limits to 400 / 15 min and 4,000 / day from the app's page. Nothing here needs changing: the backoff reads the actual limits off the response headers.

Nothing here can delete anything; Strava dropped DELETE /activities from the API, so deletions are always yours to make in the browser.

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