Last Friday (14-10-2022) I was trying to renew my github profile and I thought that it would be nice to share something outside “coding stuff”, so that whoever visits it might get to know a little bit more about me.
Three months ago I decided to fight some anxiety episodes by running away from them (literally). After becoming a runner and having run for a while I thought that would be cool to share my strava stats.
Share a summary of the strava stats in my github profile
If you go to your profile in strava, you will find a share button in the bottom right corner that says “Share Activities”. If you press this button, a pop-up will appear with some html code (an iframe) that you can embed into your code. Theoretically markdown lets you use inline html, see this post.
The result:
As I suspected, even though markdown allows inline html, github does not render it. It makes sense for security reasons. At this point I thought maybe it does not work in my github profile but there is not reason why it should not work in github pages.
Before trying anything in github pages, I wanted to create a simple html with the iframe snippet embedded.
<html>
<body>
<iframe
height="160"
width="300"
frameborder="0"
allowtransparency="true"
scrolling="no"
src="https://www.strava.com/athletes/16733304/activity-summary/7193d786bd475377789a5eb93321d996755cde76"
>
</iframe>
</body>
</html>
Surprisingly the iframe is not rendering the right stats. In fact, it looks like it is not rendering any stats at all.
Yet these are the real stats in my public profile:
At this point I opened a ticket in strava support and totally parked the iframe idea.
As the public profile is loading the right stats, my mind tells me that the next natural approach is to parse the DOM in that public page and gather the stats.
Thanks to elixir scripting introduced in elixir
1.12 we can easily
achieve this by using req and floki with
Mix.install/2
:
Mix.install([
{:req, "~> 0.2"},
{:floki, "~> 0.33.1"}
])
"https://www.strava.com/athletes/1673330"
|> Req.get!()
|> Map.fetch!(:body)
|> Floki.parse_document()
Unfortunately this does not work as the stats shown in the public profile are loaded after the DOM has been rendered by some 💩 jQuery or who knows 🤷. This outcome drove me to my latest iteration and final solution.
Note: you actually need to be authenticated to see those stats :sweat_smile:
You need to create an strava application, see the docs.
To be able to perform requests the Strava API without user interaction, we need to get a OAuth refresh token. We can build a simple router with plug and strava to print the refresh token like this:
defmodule StravaSync.Router do
use Plug.Router
plug Plug.Logger
plug :match
plug :dispatch
get "/activities" do
url =
"http://www.strava.com/oauth/authorize?client_id=#{Strava.client_id()}&response_type=code" <>
"&redirect_uri=#{URI.encode_www_form(Strava.redirect_uri())}&approval_prompt=force" <>
"&scope=read_all,profile:read_all,activity:read_all,activity:write"
body = "<html><body>You are being <a href=\"#{url}\">redirected</a>.</body></html>"
conn
|> put_resp_header("location", url)
|> send_resp(conn.status || 302, "text/html", body)
end
get "/authenticate" do
conn =
%{
query_params: %{
"code" => code
}
} = conn |> fetch_query_params()
%OAuth2.Client{
token: %OAuth2.AccessToken{
refresh_token: refresh_token
}
} = Strava.Auth.get_token!(code: code, grant_type: "authorization_code")
IO.inspect(refresh_token, label: RefreshToken)
send_resp(conn, 200, "application/json", Jason.encode!(%{refresh_token: refresh_token}))
end
match _ do
send_resp(conn, 404, "text/html", "oops")
end
defp send_resp(conn, status, content_type, body) do
conn
|> put_resp_content_type(content_type)
|> send_resp(conn.status || status, body)
end
end
Once you visit localhost:4000/activities
and you follow the OAuth
authorization flow, you need to save the refresh_token
somewhere. If you the
router code snippet there is a IO.puts
that will print it for us.
After getting the refresh token you can ask for new access tokens if you hit
the refresh token url with your client_id
and client_secret
as query
params.
Mix.install([
{:req, "~> 0.2"},
{:timex, "~> 3.7.9"}
])
strava_client_id = System.get_env("STRAVA_CLIENT_ID")
strava_refresh_token = System.get_env("STRAVA_REFRESH_TOKEN")
strava_client_secret = System.get_env("STRAVA_CLIENT_SECRET")
refresh_token_url =
"https://www.strava.com/oauth/token?" <>
"grant_type=refresh_token&refresh_token=#{strava_refresh_token}&" <>
"client_id=#{strava_client_id}&client_secret=#{strava_client_secret}"
%{
body: %{
"access_token" => access_token,
"refresh_token" => refresh_token
}
} = Req.post!(refresh_token_url)
...
By hitting the refresh token url beforehand and always, we ensure that anytime
we hit the activities url, our access_token
will be valid; making our stats
gathering script idempotent. Now we can get the activities like so:
...
after_unix = Date.utc_today() |> Timex.shift(months: -1) |> Timex.to_unix()
{:ok, %{body: activities}} =
Req.get("https://www.strava.com/api/v3/athlete/activities?after=#{after_unix}&per_page=200",
headers: [{"Authorization", "Bearer #{access_token}"}]
)
...
I wanted the stats of the last month, that is why after_unix is in place.
All of this is looking quite sexy so far, but, how do I show this in the readme? If I want the stats to be shown in my profile, I need to use some kind of templating system that allows me to build the stats and produce a new updated readme.
This is when EEX comes to play:
...
new_readme =
"README.md.eex"
|> File.read!()
|> EEx.eval_string(
number_of_runs: number_of_runs,
total_elevation_gain: :erlang.float_to_binary(summary.total_elevation_gain, decimals: 2),
total_time: Format.to_hh_mm_ss(summary.total_time),
total_distance:
summary.total_distance |> Kernel./(1000) |> :erlang.float_to_binary(decimals: 2),
from_date: after_unix |> DateTime.from_unix!() |> DateTime.to_date(),
to_date: DateTime.utc_now() |> DateTime.to_date()
)
File.write!("README.md", new_readme)
and the README.md.eex
contains:
* Runs: <%= number_of_runs %>
* Elevation Gain: <%= total_elevation_gain %> m
* Total time: <%= total_time %>
* Total distance: <%= total_distance %> km
If you know me, you will already be aware that if something can be automated, I will do so.
In this case I wanted to:
I did this via github_actions:
---
- name: Strava sync
env:
STRAVA_CLIENT_ID: $
STRAVA_REFRESH_TOKEN: $
STRAVA_CLIENT_SECRET: $
run: elixir strava_sync.ex
- name: Get current date
id: date
run: echo "::set-output name=date::$(date +'%Y-%m-%d')"
- name: Commit changes
uses: EndBug/add-and-commit@v9
with:
author_name: Josep Lluis Giralt D'Lacoste
author_email: josepgiraltdlacoste@gmail.com
message: "Strava sync $"
add: "*.md"
love ❤️ and peace 🐦 to all