Repository navigation
Data Source Scrape Mode
Scrape mode uses the same internal endpoints Strava's own web dashboard uses, authenticated with a browser session cookie (_strava4_session). No Strava API subscription or OAuth app required.
Two distinct scripts have scrape mode:
-
My Activities scrape mode (
STRAVA_MY_SOURCE=scrape) — replaces the Strava/athlete/activitiesAPI -
Club leaderboard scrape mode (
STRAVA_SOURCE=scrape) — replaces the/clubs/{id}/activitiesAPI (deprecated 2026-09-01)
When your Strava API subscription lapses or you never had one.
1. Get the session cookie:
- Log in to strava.com in any browser.
- Open DevTools → Application → Cookies →
www.strava.com. - Find
_strava4_sessionand copy its Value (a long alphanumeric string).
Sessions last approximately 30 days. When the script says STRAVA_SESSION_COOKIE has expired, repeat this step.
2. Edit the config:
vi /etc/strava-my-activities.confAdd or change:
STRAVA_MY_SOURCE="scrape"
STRAVA_SESSION_COOKIE="<paste _strava4_session value here>"The OAuth credentials (STRAVA_CLIENT_ID etc.) can remain but are ignored in scrape mode.
3. Run once to verify:
strava-my-activities- Uses Strava's internal
/athlete/training_activitiesendpoint (the same one Strava's own dashboard uses) - Normalizes locale-formatted distances, string times, and locale dates automatically
- Backfills per-activity detail by fetching each activity's HTML page and extracting stats (distance, time, elevation, cadence, power, gear, steps, calories)
- Downloads the GPX export (
/activities/{id}/export_gpx) and saves it alongside the detail file — the interactive route map onactivity.htmlrenders the full GPS track via Leaflet - Walk / Run / Hike detail — per-km splits are computed client-side from the GPX track (haversine + timestamps + elevation + HR + cadence); actual device step count is shown when the page carries it, with a cadence-based estimate as fallback
Existing activities are matched by Strava ID and updated in place — no duplicates when switching from API mode to scrape mode.
The Data completeness page (/strava/me/data-quality.html) shows a green / amber / red status card indicating when the _strava4_session cookie was last verified and when it needs to be refreshed (~30 days). The section is hidden in API mode and when no scrape-mode config is active.
An inline form on the same page lets you paste a fresh _strava4_session value directly from the browser (DevTools → Application → Cookies → strava.com); saving it calls /cgi-bin/update-cookie, which writes the new value to both My Activities and leaderboard configs and clears the cached session files so the next sync re-verifies it.
Three silent failure conditions send a consolidated alert email once per day (rate-limited by $STATE_DIR/scrape-alert.txt):
| Trigger | What happened |
|---|---|
| Strava layout change | Detail page parse produced no usable data — Strava may have changed their HTML |
| Cookie expired mid-run | Session cookie was rejected during detail backfill |
| Normalization failure |
jq could not parse an activity list page |
Alerts go to STRAVA_MY_BIKE_EMAIL (the same address used for bike-service threshold alerts). No additional config key is needed — if that address is set, both bike and scrape alerts are active.
Use the provided PowerShell helper (requires Podman):
powershell -ExecutionPolicy Bypass -File test\run-scrape-test.ps1 `
-SessionCookie "<paste _strava4_session value>" `
-MaxPages 3 -DetailMax 10 -KeepOutputStrava removes the /clubs/{id}/activities API on 2026-09-01. Switch to STRAVA_SOURCE=scrape when the API stops responding.
Scrape mode gives real activity dates and Strava IDs (not first-seen approximations).
- Log in to strava.com in any browser.
- Open DevTools → Application → Cookies →
www.strava.com. - Find
_strava4_sessionand copy its Value.
Sessions last approximately 30 days. When the script says STRAVA_SESSION_COOKIE has expired, repeat this step.
vi /etc/strava-leaderboard.confAdd or change:
STRAVA_SOURCE="scrape"
STRAVA_SESSION_COOKIE="<paste _strava4_session value here>"
STRAVA_CLUB_IDS="123456,789012"The OAuth credentials can remain but are ignored in scrape mode.
The Strava club feed covers roughly the last 3–4 weeks of activity. Those same activities are already in the store from the API's last few runs (with content-hash signatures). On the first scrape run, the dedup check looks for numeric IDs, not content hashes — those overlap-period activities would be added again.
To avoid this, set STRAVA_SCRAPE_START_DATE in /etc/strava-leaderboard.conf before the first scrape run:
# ~4 weeks before your switch date
STRAVA_SCRAPE_START_DATE="2026-09-01"The script silently ignores any scraped activity whose actual start date is before this value. Once the overlap window has passed, you can comment this line out.
If you skip this, the duplication resolves within one month — duplicated activities fall out of the current-month filter and only affect all-time totals briefly. It is cosmetic, not data-destroying.
scp strava-leaderboard.sh root@192.168.1.1:/usr/bin/strava-leaderboard `
&& scp strava-lib.sh root@192.168.1.1:/usr/bin/strava-lib.shNo install.sh re-run needed unless you changed paths or cron timing.
2026-09-15 23:50:01 reusing cached Strava CSRF token
2026-09-15 23:50:01 fetching club 123456 feed (cursor pagination)...
2026-09-15 23:50:02 page 1: 15 entries
2026-09-15 23:50:02 page 2: 14 entries
2026-09-15 23:50:03 page 3: empty, stopping
2026-09-15 23:50:03 fetched 29 entries from club 123456 (actual dates)
2026-09-15 23:50:03 club 123456: +5 new (actual dates), 412 total
2026-09-15 23:50:05 wrote /www/strava/activities.json and per-club leaderboard JSON (snapshot 20260915)
2026-09-15 23:50:05 wrote /www/strava/index.html
2026-09-15 23:50:05 done.
The .ndjson store is additive. API-mode entries use a content-hash string as their signature; scrape-mode entries use the numeric Strava activity ID. They never collide:
# API entry — content-hash signature, approximate first-seen date:
{"signature":"jakub|r|morning ride|34300|5400|ride","firstSeen":"2026-06-15",...}
# Scrape entry — Strava activity ID signature, real date:
{"signature":12345678901,"firstSeen":"2026-08-22",...}
Old entries keep their first-seen dates; new scrape-mode entries get real dates going forward.
The club feed returns each activity's three headline stats (stat_one, stat_two, stat_three) in an order that depends on the activity type, and labels each one with a *_subtitle (Distance, Elev Gain, Time, Pace, Cal, Avg HR, Avg Power, …). For example:
# Outdoor ride: Distance / Elev Gain / Time
# Run: Distance / Pace / Time
# Indoor ride (HR): Time / Cal / Avg HR ← no Distance stat at all
The parser reads distance/elevation/time from the stat whose subtitle names it, not from a fixed position. (Older feeds without subtitles fall back to the positional assumption.) This prevents a value like "14m 6s" (Time) from being mis-read as 146 km of distance.
On top of that, four guards drop junk, so a distance-less or mis-parsed activity never lands on the distance leaderboard:
| Guard | Drops |
|---|---|
distance > 0 |
distance-less activities (indoor cycling, yoga, weights, …) |
distance ≤ 2000 km |
impossible single-activity distances |
moving_time > 0 |
a non-zero distance paired with zero time — the mis-parse where an indoor ride's 1h 40m time stat is read as 140 km (its own time stat then parses to 0) |
| average speed ≤ 80 km/h | physically-impossible speeds that flag a unit/stat mis-parse |
Distances are stored as whole metres (the parser rounds km × 1000), so the .ndjson store stays free of floating-point noise.
Retroactive clean-up: these guards run in two places. They filter incoming feed entries on the way into the store, and a sanity-purge pass re-applies them to every row already in activities_<clubid>.ndjson on each run. So a bad row written by an older parser — before this activity type was understood — is removed automatically on the next strava-leaderboard run; no manual editing is needed. The run log reports it as club <id>: purged N implausible stored entries (zero-time/over-speed/over-distance). (A distance-less indoor row with distance 0 is kept — it contributes nothing to the distance board and is not a mis-parse.)
Test against real Strava data without touching the router's live leaderboard. Prerequisites: Podman (or Docker), PowerShell.
# From the repo root:
$env:STRAVA_SESSION_COOKIE = "<paste _strava4_session value>"
$env:STRAVA_CLUB_IDS = "123456,789012"
# Run scrape + open dashboard in browser:
powershell -ExecutionPolicy Bypass -File test\run-scrape-test.ps1 -Serve
# Or just run without opening browser:
powershell -ExecutionPolicy Bypass -File test\run-scrape-test.ps1The -Serve flag starts a Python HTTP server on port 8088 (change with -Port 9090). The browser opens automatically. Press Enter to stop.
The test builds a minimal Alpine image, runs strava-leaderboard.sh with STRAVA_SOURCE=scrape, writes HTML/JSON to a Windows temp directory, and serves that directory. The router's live leaderboard is completely unaffected.