Small scripts for poking at Strava data. Standard library only (no pip install).
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.
python3 get_token.pyPrompts 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.
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-012024-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.
Bulk-import GPX files, one upload at a time.
python3 import_gpx.py ./tracks --sport-type Run312 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.
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.
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.
Reports only, never deleted.