Strava API로 그날의 운동기록을 가져오는 아이폰 단축어

지난번 만든 자동으로 일기쓰는 아이폰 단축어매일 같이 자동 일기가 써지고 있다. 아주 좋다. 확실히 하루하루가 기록되니까 시간 가는 것이 덜 아쉽다. 신용카드 내역보고 그날 뭔일 있었지? 싶을 때 일기들 찾아보면 다 기억난다. 소람님 아이폰 단축어를 내가 원하는 기능으로 바꿨고, X(트위터) API로 그날 트윗을 가져오는 것도 추가했다.

 

업데이트한 자동일기 구성

 

이번에는 윗 그림의 완성을 위해 Strava API로 그날의 운동기록을 추가해보자. 

 

Strava API가 생소하기도 하고, 운동기록이 좀 복잡하기도 하니 다음 단계로 진행한다. (이것이 바로 CoT, Chain of Thought)

  1. Strava API 작동 방식 이해 
  2. 그날의 운동기록을 가져오는 파이썬 스크립트 작성
  3. 파이썬 스크립트를 아이폰 단축어로 변경 
  4. 자동일기 단축어에 추가 

그럼 시작!

 

Strava API 작동 방식 이해

Strava API 공식 문서를 읽어본다. 엔드포인트는 List Athelete Activities를 쓰면 될 것 같다.

 

 

추출할 시작 시간(after)과 끝 시간(before)을 지정하고 그 시간대 활동을 가져오면 된다. refresh token 도 있고, access token 도 있는 것으로 보아, referesh token 으로 access token을 받은 후, 이걸로 활동을 가져오는 것으로 보인다. 하나하나 스크립트를 만들기전 시대가 시대이니 만큼 GPT-4o로 일단 시켜본다. 

 

[질문]
오늘의 strava 운동기록을 가져와서 자동으로 운동 일기를 써주는 파이썬 스크립트를 만드려고 해.  Strava API v3를 써서 할꺼야. "List Athelete Activities" endpoint 를 써서 해야 할 것 같은데, 처음 접속 가능한 토큰을 만들고 갱신하는 것 포함해서 오늘의 모든 운동기록을 가져오는 파이썬 스크립트를 작성해 주세요.

 

이 질문에 바로 짠 하고, 코드까지 만들어준다. client id, client secret, refresh token을 실제 값으로 바꿔서 코드를 실행했더니! 역시나 한번에는 안된다. authorization error 라면서 결과가 안나오는데, 이부분은 계속 GPT-4o에게 물어봐도 해결책이 안나온다. 

 

문서를 다시 읽어보고, 구글링해봐도 잘 안된다. 뭔가 데이터를 가져오기 위한 scope 설정이 필요하다고 하는데, GPT-4o가 하라는데로 해선 아무리 해도 안된다. GPT 이녀석 안되면 다른 방법을 제시해줘야지 그냥 계속 안되는 방법을 알려준다. 그러다 딱 나의 상황을 설명한 문서를 구글검색으로 찾았고, GPT-4o의 코드를 적절히 수정하여 문제를 해결했다.  이 과정을 처음부터 설명하자면, 

 

Strava 사이트에 로그인하고 Setting --> My API Application 메뉴에서 앱을 하나 만든다. 

 

그러면, Client ID, Client Secret, Access Token, Refresh Token 값을 알 수 있다. 이들을 일단 메모장에 복사해 둔다. 위의 스크린샷에 Access Token의 scope:read 가 보일것이다. 이 Access Token 은 scope:read 만 가능하고, 문동목록을 가져올 수는 없다. 이를 위해서는 scope이 read,activity:read-all 인 Refresh Token을 따로 받고, 이걸로 Access Token을 받아서 해야 한다.

 

Strava 웹사이트에 로그인 된 상태에서 아래 URL을 웹브라우저 주소창에 붙혀넣는다. 

http://www.strava.com/oauth/authorize?client_id=[REPLACE_WITH_YOUR_CLIENT_ID]&response_type=code&redirect_uri=http://localhost/exchange_token&approval_prompt=force&scope=read

 

위 URL에서 수정해야 할 곳이 두곳 있다. [REPLACE_WITH_CLIENT_ID] 부분을 숫자로 된 나의 client id로 바꾸고, 마지막 부분의 "scop=read""scope=activity:read_all" 로 바꾼다. 그러고 엔터를 입력하면, 다음과 유사한 페이지가 표시된다. 

 

 

Authorize 버튼을 누르면, 엉뚱하게도 없는 localhost 웹사이트로 이동한다. 이동하는 URL에 code가 표시된다. 

 

 

이 code를 복사한 후, 터미널서 다음 명령을 실행한다. 

 

curl -X POST https://www.strava.com/api/v3/oauth/token \
    -d client_id=[CLIENT_ID] \
    -d client_secret=[CLIENT_SECRET] \
    -d code=[COPIED_CODE] \
    -d grant_type=authorization_code

 

client_id, client_secret, code 가 제대로 입력되었다면, 정상적인 JSON 응답을 받게 된다. 

 

{
    "token_type":"Bearer",
    "expires_at":1742486336,
    "expires_in":21600,
    "refresh_token":"xxxxx",
    "access_token":"xxxxx",
    "athlete":{
        "id":89382949,
        "username":"yong27",
        "resource_state":2,
        "firstname":"Hyungyong",
        "lastname":"Kim",
        ...(생략)...
    }
}

 

저 응답에서 받은 refresh_token 이 중요하다. 메모장에 잘 적어 둔다.

 

그날의 운동기록을 가져오는 파이썬 스크립트

위에 적은 refresh_token과 client_id, client_secret 를 각각 GPT-4o가 만든 파이썬 코드에 적어주고 실행하면 정상 동작한다. 

 

import requests
import datetime
import json
import argparse
from dateutil import parser  # ISO 8601 날짜 변환용

# Strava API credentials
CLIENT_ID = "xxxxx"
CLIENT_SECRET = "xxxxx"
REFRESH_TOKEN = "xxxxx"
TOKEN_URL = "https://www.strava.com/oauth/token"
ACTIVITIES_URL = "https://www.strava.com/api/v3/athlete/activities"


def get_access_token():
    """ Strava API에서 액세스 토큰을 가져오거나 갱신하는 함수 """
    payload = {
        "client_id": CLIENT_ID,
        "client_secret": CLIENT_SECRET,
        "refresh_token": REFRESH_TOKEN,
        "grant_type": "refresh_token"
    }
    response = requests.post(TOKEN_URL, data=payload)
    token_info = response.json()

    if "access_token" not in token_info:
        print(f"⚠️ 액세스 토큰을 가져오지 못했습니다. 응답 확인: {token_info}")
        exit(1)

    return token_info["access_token"]


def get_activities_by_date(access_token, date):
    """ 특정 날짜의 운동 기록을 가져오는 함수 """
    start_of_day = int(datetime.datetime.strptime(date, "%Y-%m-%d").timestamp())
    end_of_day = start_of_day + 86400  # 하루(24시간) 후 타임스탬프
    
    headers = {"Authorization": f"Bearer {access_token}"}
    params = {"after": start_of_day, "before": end_of_day}
    response = requests.get(ACTIVITIES_URL, headers=headers, params=params)

    if response.status_code != 200:
        print(f"⚠️ API 요청 실패: {response.status_code}, 응답 내용: {response.text}")
        return []

    activities = response.json()

    # 응답이 리스트인지 확인
    if not isinstance(activities, list):
        print(f"⚠️ 예상한 리스트가 아닙니다. 응답 내용 확인: {activities}")
        return []

    return activities


def format_time(timestamp_str):
    """ISO 8601 형식의 시간을 변환하는 함수"""
    if not timestamp_str:
        return "시간 없음"

    try:
        local_time = parser.parse(timestamp_str)  # ISO 8601 문자열을 datetime 객체로 변환
        return local_time.strftime("%p %I:%M").replace("AM", "오전").replace("PM", "오후")
    except Exception as e:
        print(f"⚠️ 시간 변환 오류: {e}")
        return "시간 오류"


def generate_workout_log(activities, date):
    """ 운동 기록을 분석하여 일기 내용을 자동 생성 """
    if not activities:
        return f"{date}에는 운동을 하지 않았습니다. 내일은 운동을 해볼까요? 💪"

    log = f"{date}의 운동 기록:\n\n"
    total_distance = 0
    total_duration = 0

    for activity in activities:
        if not isinstance(activity, dict):
            continue

        name = activity.get("name", "이름 없음")
        start_time = format_time(activity.get("start_date", ""))
        distance_km = round(activity.get("distance", 0) / 1000, 2)
        moving_time = activity.get("moving_time", 0) // 60  # 초 -> 분 변환
        activity_type = activity.get("type", "운동 유형 없음")

        # 러닝과 사이클링에 따라 평균 속도/페이스 계산
        if activity_type == "Run":
            pace_per_km = round(moving_time / distance_km, 2) if distance_km > 0 else 0
            pace_str = f"평균 페이스: {pace_per_km} 분/km"
        elif activity_type == "Ride":
            avg_speed = round(activity.get("average_speed", 0) * 3.6, 2)  # m/s -> km/h 변환
            pace_str = f"평균 속도: {avg_speed} km/h"
        else:
            pace_str = ""

        total_distance += distance_km
        total_duration += moving_time

        log += f" * ({start_time}) {name} - {moving_time}분 {distance_km}km {pace_str}\n"

    log += f"\n총 운동 거리: {total_distance} km\n총 운동 시간: {total_duration}분"
    log += "\n\n운동하느라 고생 많았어요! 😊"
    return log


if __name__ == "__main__":
    p = argparse.ArgumentParser()
    p.add_argument("--date", type=str, default=str(datetime.date.today()), help="운동 기록을 가져올 날짜 (YYYY-MM-DD)")
    args = p.parse_args()
    
    access_token = get_access_token()
    activities = get_activities_by_date(access_token, args.date)
    workout_log = generate_workout_log(activities, args.date)
    
    print(workout_log)
    
    # JSON 파일로 저장
    with open(f"workout_log_{args.date}.json", "w", encoding="utf-8") as f:
        json.dump({"date": args.date, "log": workout_log}, f, ensure_ascii=False, indent=4)

 

GPT-4o가 생성한 코드는 뭔가 내 맘에 쏙 들진 않는다. 불필요해 보이는 기능도 자기가 알아서 넣고, 출력에 이모티콘을 넣은건 좀 귀엽긴 하다. 암튼 정상 동작하는 파이썬 코드가 만들어졌다. 이를 실행해보면, 

 

$ uv run main.py --date 2025-03-09
2025-03-09의 운동 기록:

 * (오전 03:21) 점심 산책 콩나물국밥 - 40분 2.84km
 * (오전 01:53) Morning Yoga - 35분 0.0km
 * (오후 10:33) Morning Run - 60분 10.5km 평균 페이스: 5.71 분/km

총 운동 거리: 13.34 km
총 운동 시간: 135분

운동하느라 고생 많았어요! 😊

 

시간 변환이 틀렸고, 달리기 페이스가 5.71 분/km 인 것도 그렇고 맘에 안드는 부분이 많긴 하지만, 내 목표는 파이썬 코드가 아니라 아이폰 단축어이므로 일단 넘어간다.  

 

파이썬 스크립트를 아이폰 단축어로 변경

파이썬 스크립트를 보면서, 하나하나 아이폰 단축어로 만들었다. 이름은 "Get Today Strava activities". 시작시간, 끝시간에 유닉스 타임스탬프를 입력해야 하는데, 이게 별도 함수로 없네. 1970-01-01 과의 날짜 차이를 초 단위로 편집하여 쿼리의 변수로 담는다. "URL 콘텐츠 가져오기"로 API 호출하는데, 이 부분이 테스트가 제일 많이 필요하다. 뭔가 안될 때 왜 안되는지 알 수 없어서 디버깅이 어렵다. 많은 시도 끝에 일단은 정상 동작 확인. X 때도 마찬가지고 목록을 역순으로 하는 것이 쉽지 않다. 일단 그냥 역순으로 출력한다. (Get Today Strava activities 단축어 다운로드)

Get Today Strava activities 단축어의 일부 화면 캡처

 

알 수 없는 이유로 돼야 할 기능이 안될 경우가 있는데, 아이폰 재부팅하면 될 때가 있다. 아마도 디버깅하면서 자주 실행하다보면 뭔가 메모리가 꼬이는 듯함. 

 

자동일기 단축어에 추가

기존의 자동일기 단축어인 "Daily Journal"에서 "Get Today Strava activities" 단축어를 실행하도록 설정한다. 그리하고 실행하면, 짜잔... 자동일기에 그날의 스트라바 운동내역이 추가된다. 불끈! 

 

자동일기에 추가된 Tweets와 Strava activities

 

며칠간 이 단축어 만들면서 또 몰랐던 것을 알 수 있었고, 기존 단축어 버그도 수정할 수 있었다. 

 

갑자기 생각났는데, 그날 일기 맨 앞에 자동으로 날씨를 넣어주면 그것도 좋겠다. 이건 추후에 구현하는걸로! 

반응형