Files
strava/main.go
Thomas Klaehn 2c2969c48e Initial commit
Signed-off-by: Thomas Klaehn <thomas.klaehn@perinet.io>
2025-10-02 12:05:15 +00:00

436 lines
11 KiB
Go

package main
import (
"bytes"
"encoding/json"
"errors"
"flag"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type config struct {
ClientId string `json:"client_id"`
ClientSecret string `json:"client_secret"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
OutputPath string `json:"output_folder"`
}
type refresh_token_request struct {
ClientId string `json:"client_id"`
ClientSecret string `json:"client_secret"`
RefreshToken string `json:"refresh_token"`
GrantType string `json:"grant_type"`
}
type refresh_token_response struct {
TokenType string `json:"token_type"`
AccessToken string `json:"access_token"`
Expires_at json.Number `json:"expires_at"`
Expires_in json.Number `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
}
type stream struct {
Type string `json:"type"`
Data []interface{} `json:"data"`
SeriesType string `json:"series_type"`
OriginalSiye json.Number `json:"original_size"`
Resolution string `json:"resolution"`
}
type activity struct {
ResourceState json.Number `json:"resource_state"`
Athlete struct {
Id json.Number `json:"id"`
ResourceState json.Number `json:"resource_state"`
} `json:"athlete"`
Name string `json:"name"`
Distance json.Number `json:"distance"`
MovingTime json.Number `json:"moving_time"`
ElapsedTime json.Number `json:"elapsed_time"`
TotalElevationGain json.Number `json:"total_elevation_gain"`
Type string `json:"type"`
SportType string `json:"sport_type"`
Id json.Number `json:"id"`
ExternalId string `json:"external_id"`
UploadId json.Number `json:"upload_id"`
StartDate time.Time `json:"start_date"`
StartDateLocal time.Time `json:"start_date_local"`
TimeZone string `json:"timezone"`
UtcOffset json.Number `json:"utc_offset"`
Trainer bool `json:"trainer"`
Commute bool `json:"commute"`
Manual bool `json:"manual"`
Private bool `json:"private"`
Flagged bool `json:"flagged"`
AverageSpeed json.Number `json:"average_speed"`
MaxSpeed json.Number `json:"max_speed"`
AverageCadence json.Number `json:"average_cadence"`
AverageWatts json.Number `json:"average_watts"`
WeightedAverageWatts json.Number `json:"weighted_average_watts"`
KiloJoules json.Number `json:"kilojoules"`
DeviceWatts bool `json:"device_watts"`
HasHeartrate bool `json:"has_heartrate"`
AverageHeartrate json.Number `json:"average_heartrate"`
MaxHeartrate json.Number `json:"max_heartrate"`
MaxWatts json.Number `json:"max_watts"`
SufferScore json.Number `json:"suffer_score"`
// Streams []stream `json:"streams"`
}
var (
logger = *log.Default()
config_path string
config_cache config
access_token_path string
error_too_many_req = errors.New("Too Many Requests")
)
func init() {
logger.SetFlags(log.Llongfile | log.Ltime)
logger.Print("starting...")
}
func read_config() {
data, err := os.ReadFile(config_path)
if err != nil {
logger.Printf("Unable to read %s", config_path)
return
}
err = json.Unmarshal(data, &config_cache)
if err != nil {
logger.Print("Unable to evaluate config data")
return
}
}
func get_remote_access() {
logger.Print("Requesting new access token.")
url := "https://www.strava.com/oauth/token"
ref := refresh_token_request{
ClientId: config_cache.ClientId,
ClientSecret: config_cache.ClientSecret,
GrantType: "refresh_token",
RefreshToken: config_cache.RefreshToken,
}
res, err := json.Marshal(ref)
if err != nil {
logger.Panic(err)
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(res))
if err != nil {
logger.Panic(err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
logger.Panic(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
logger.Panic(resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
logger.Panic(err)
}
var access_token refresh_token_response
err = json.Unmarshal(body, &access_token)
if err != nil {
logger.Panic(err)
}
f, err := os.Create(access_token_path)
if err != nil {
logger.Panic(err)
}
data, err := json.Marshal(access_token)
f.Write(data)
}
func get_remote_access_token() (*refresh_token_response, error) {
logger.Printf("Reading remote access token from %s.\n", access_token_path)
data, err := os.ReadFile(access_token_path)
if err != nil {
get_remote_access()
}
data, err = os.ReadFile(access_token_path)
if err != nil {
return nil, err
}
var token refresh_token_response
err = json.Unmarshal(data, &token)
if err != nil {
return nil, err
}
expire, _ := token.Expires_at.Int64()
if expire < time.Now().Unix() {
get_remote_access()
data, err = os.ReadFile(access_token_path)
if err != nil {
return nil, err
}
err = json.Unmarshal(data, &token)
if err != nil {
return nil, err
}
}
return &token, nil
}
func get_remote_activities_since(token refresh_token_response, date time.Time) (*[]activity, error) {
logger.Printf("Download acitivities after %s\n", date)
loop_count := 1
client := &http.Client{}
value := token.TokenType + " " + token.AccessToken
var ret []activity
var after int = 0
if date.Unix() > int64(after) {
after = int(date.Unix())
}
for {
url := "https://www.strava.com/api/v3/athlete/activities?per_page=200&page=" + strconv.Itoa(loop_count) + "&after=" + strconv.Itoa(after)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", value)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
if resp.StatusCode == 429 {
return nil, error_too_many_req
}
logger.Print(resp.Status)
logger.Panic(resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var activities []activity
err = json.Unmarshal(body, &activities)
if err != nil {
return nil, err
}
ret = append(ret, activities[:]...)
if len(activities) < 200 {
break
}
loop_count++
}
return &ret, nil
}
// func get_processed_ids() []json.Number {
// logger.Print("Getting already processed IDs")
// files, err := os.ReadDir(config_cache.OutputPath)
// if err != nil {
// logger.Panic(err)
// }
// var ret []json.Number
// for _, file := range files {
// res, err := os.ReadFile(config_cache.OutputPath + "/" + file.Name())
// if err != nil {
// continue
// }
// var act activity
// err = json.Unmarshal(res, &act)
// if err != nil {
// continue
// }
// ret = append(ret, act.Id)
// }
// return ret
// }
func get_last_processed_date(folder string) *time.Time {
logger.Print("Getting date of last processed activity.")
files, err := os.ReadDir(folder)
if err != nil {
logger.Panic(err)
}
var dates []time.Time
for _, file := range files {
res, err := os.ReadFile(config_cache.OutputPath + "/" + file.Name())
if err != nil {
continue
}
var act activity
err = json.Unmarshal(res, &act)
if err != nil {
continue
}
dates = append(dates, act.StartDate)
}
ret := new(time.Time)
for _, date := range dates {
if date.After(*ret) {
*ret = date
}
}
return ret
}
// func get_unprocessed_activities(all_acts []activity, processed_ids []json.Number) []activity {
// logger.Print("Sort out processed activities out of all activities")
// var ret []activity
// out:
// for _, act := range all_acts {
// for _, id := range processed_ids {
// if id == act.Id {
// continue out
// }
// }
// ret = append(ret, act)
// }
// return ret
// }
// func act_add_streams(act *activity, token refresh_token_response) error {
// logger.Print("Add stream to activity")
// id, err := act.Id.Int64()
// if err != nil {
// return err
// }
// url := "https://www.strava.com/api/v3/activities/" + strconv.Itoa(int(id)) + "/streams?keys=time,distance,latlng,altitude,velocity_smooth,heartrate,cadence,watts,temp,moving,grade_smooth"
// req, err := http.NewRequest("GET", url, nil)
// if err != nil {
// return err
// }
// client := &http.Client{}
// value := token.TokenType + " " + token.AccessToken
// req.Header.Set("Authorization", value)
// resp, err := client.Do(req)
// if err != nil {
// return err
// }
// defer resp.Body.Close()
// if resp.StatusCode != http.StatusOK {
// if resp.StatusCode == 429 {
// return error_too_many_req
// }
// logger.Print(resp.Status)
// logger.Panic(resp.StatusCode)
// }
// body, err := io.ReadAll(resp.Body)
// if err != nil {
// return err
// }
// err = json.Unmarshal(body, &act.Streams)
// if err != nil {
// return err
// }
// return nil
// }
func store_activity(act activity, folder string) error {
file_name := folder + "/" + act.StartDate.Format("2006-01-02_15:04:05") + ".json"
file_name = strings.Replace(file_name, ":", "_", -1)
logger.Printf("Store activity %s\n", file_name)
f, err := os.Create(file_name)
if err != nil {
return err
}
defer f.Close()
json, err := json.MarshalIndent(act, "", "\t")
if err != nil {
return err
}
_, err = f.Write(json)
return err
}
func main() {
flag.StringVar(&config_path, "config-path", "./config/config.json", "Specify path to find the config file. Default is ./config/config.json")
flag.StringVar(&access_token_path, "access-token-path", "./config/access_token.json", "Specify path to find the access token. Default is ./data/access_token.json")
flag.Parse()
read_config()
// create output path if not exisisting
_, err := os.Stat(config_cache.OutputPath)
if err != nil {
if os.IsNotExist(err) {
logger.Printf("Creating data storage directory: %s\n", config_cache.OutputPath)
err = os.MkdirAll(config_cache.OutputPath, os.ModePerm)
if err != nil {
logger.Panic(err)
}
} else {
logger.Panic(err)
}
}
restart:
// processed_ids := get_processed_ids()
token, err := get_remote_access_token()
if err != nil {
logger.Panic(err)
}
last := get_last_processed_date(config_cache.OutputPath)
acts, err := get_remote_activities_since(*token, *last)
if err != nil {
if err == error_too_many_req {
logger.Print(err)
logger.Print("waiting for 15 minutes")
time.Sleep(time.Minute*15 + time.Second*15)
goto restart
} else {
logger.Panic(err)
}
}
for _, act := range *acts {
err = store_activity(act, config_cache.OutputPath)
if err != nil {
logger.Panic(err)
}
}
// unprocessed_acts := get_unprocessed_activities(*acts, processed_ids)
// for _, act := range unprocessed_acts {
// redo:
// err = act_add_streams(&act, *token)
// if err != nil {
// if err == error_too_many_req {
// logger.Print(err)
// logger.Print("waiting for 15 minutes")
// time.Sleep(time.Minute*15 + time.Second*15)
// goto redo
// } else {
// logger.Panic(err)
// }
// }
// err = store_activity(act, config_cache.OutputPath)
// if err != nil {
// logger.Panic(err)
// }
// }
}