436 lines
11 KiB
Go
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)
|
|
// }
|
|
// }
|
|
}
|