tistory API 사용기, 오류 해결기

Tistory API 사용 계기

최근 Tistory의 API를 사용할 일이 있었다. 블로그 글쓰기 대회를 하나 열었는데, 그래서 특정 기간 동안 작성된 블로그 포스트 목록을 불러와야 할 일이 생겼다. 블로그의 수가 적었기에 수동으로 집계해도 되지만, 어차피 나중을 생각하면, 똑같은 대회가 두 번 이상 안 열릴 거라는 법이 없기도 하고, 어차피 자동화 한 번 해놓으면 집계가 편하기도 하고, 다른 곳에서 사용할 일이 있을 수도 있고, 또 이런 거 자동화 안 하면 화병이 나는 사람이라... 자동화하기로 했다.

처음 계획은 웹 크롤링을 이용해볼까 하는 생각이었다. 그러나 이 생각은 곧 접게 되었는데, 왜냐면 tistory는 블로그마다 uri를 다르게 설정할 수 있기 때문이다. 또 예전에 tistory readme card라는, 프로필에 티스토리 최근 글을 표시하는 프로필 꾸미기가 있는데 뭔가 문제가 생긴 것 같아서 살펴보느라 Tistory API 문서를 읽어보기도 했었다. 그런데 생각보다 간단하고, 일단 한국어로 되어 있어서 크게 공부할 것도 없을 것 같아서, 그냥 티스토리의 API를 사용하기로 했다.

Tistory API Document

Authorization Code, Access Token 받기

티스토리의 API를 이용하기 위해서는 Access Token을 받아야 한다. 그런데 이 Access Token을 받는 과정이 조금 복잡하다. 여기서 주의해야 할 게, Tistory 앱 등록에서 받을 수 있는 CLIENT KEY와 Authorization code, Access token은 각각 다르다. 다음 과정을 통해서 발급받아야 한다.

참고로 다른 많은 블로그들에서는 수동으로 직접 Access token을 갖고 오고 있는데, 나는 이 과정 역시 자동화할 것이다. 그러나 완전 자동화는 불가능하다. Tistory가 다음 카카오에 통합되면서, 자동 로그인이 불가능해졌기 때문이다. 한편 이게 OAuth가 작동하는 방식이기도 하다. 무조건 최종 사용자의 직접적으로 개입하여 인가하는 과정이 필요하다.

먼저 Tistory 앱 등록 페이지에서 API를 사용할 앱을 등록하자. 적당히 입력해주면 된다.

나는 위와 같이 했다. 서비스 이름을 적당하게 지어주고, Redirect URI를 설정해준다. 이때 Redirect URI란, Authorization Code를 발급받을 때 사용될 리디렉션 URI를 의미한다. 이 Authorization Code는 Redirect URI + Auth Code + Status 이런 식으로 주소 창에 숨겨져 있을 것이다.

나중에 이 리디렉션 URI를 더 좋은 곳(예를 들어 LocalHost를 띄운다던지 하는 방법)으로 바꿀 테지만, 일단은 그냥 내 블로그 주소로 했다. 그리고 쓰기, 읽기 권한이 모두 체크되어 있는지 확인하고, 등록을 눌러서 App Id와 Client Key를 받아보자.

참고로 Tistory document에서조차 용어의 통일화가 이루어지지 않아서 헷갈리는 분들이 있을텐데, 여기서 말하는 Client Key가 바로 Client Secret이다. 그리고 이건 Access Token과는 다르다. 제발 용어 통일좀 해줘...

그리고 이를 사용하여 이제 Authorization Token을 받아보자. 먼저 프로젝트의 Root 디렉토리에 .env 파일을 만들고, CLIENT_ID, CLIENT_KEY, REDIRECT_URI를 입력한다. .env 파일을 이용하는 방법은 언어, 프레임워크마다 모두 각자의 라이브러리가 있으니, 해당 방법을 이용하면 된다. 나는 파이썬을 이용하겠다.

CLIENT_ID=a3c085...
SECRET_KEY=a3c08...
REDIRECT_URI=http://nx006.tistory.com/

위와 같이 환경 변수를 세팅하고

load_dotenv()

if __name__ == "__main__":
    client_id: str = os.getenv('CLIENT_ID')
    redirect_uri: str = os.getenv('REDIRECT_URI')
    secret_key: str = os.getenv('SECRET_KEY')

이런 식으로 환경변수를 받아오면 된다. 환경변수를 세팅했으니 Authorization을 받아보자. Authorization Code는 무조건 사용자의 개입이 필요하다. 사용자가 허가를 눌러야 Code가 발급되는 구조이다. 일단 Tistory API를 읽어보면

https://www.tistory.com/oauth/authorize?
  client_id={client-id}
  &redirect_uri={redirect-uri}
  &response_type=code
  &state={state-param}

위 URL을 통해 사용자가 접속하면, 인증 페이지가 열린다. 파이썬에서 위 URL로 페이지를 이동시키자.

def get_authorization_url(client_id, redirect_uri):
    url = "https://www.tistory.com/oauth/authorize"
    params = {
        "client_id": client_id,
        "redirect_uri": redirect_uri,
        "response_type": "code"
    }
    url = url + "?" + "&".join([f"{key}={value}" for key, value in params.items()])
    return url

위와 같이 함수를 짠 뒤

auth_url: str = tpc.get_authorization_url(
                client_id=client_id,
                redirect_uri=redirect_uri,
            )
webbrowser.open(auth_url)
code = input('Input provided code: ').strip()

webbrowser 라이브러리로 해당 auth_url을 웹 브라우저에서 연다. 그러면 아래 사진과 같은 화면이 뜰 것이다.

여기서 사용자가 '허가하기'를 누르면, 우리가 기존에 설정해둔 리디렉션 URI로 이동한다. 참, 이때 사소하게 주의해야 할 점은 url이 아니라 uri이다. 나도 이 오타때문에 계속 invalid request가 떠서 조금 고생했었다...

이렇게 리디렉션된 페이지가 보인다. 그리고 저기 위에 주소창에, Authorization Code 역시 보인다.

https://nx006.tistory.com/?code=b1aa6cc0a4e171dacbb0d00eb49a008d279c1972b8b0ac21e23b624a040a5da780772646&state=

참고로 이 Authorization Code는 10분 후 만료된다. 그러니 빠르게 Access Token으로 전환시키자. 사용자가 code=부터 &state 사이 코드를 복사해서, 붙여넣는다.

Input provided code: b1aa6cc0a4e171dacbb0d00eb49a008d279c1972b8b0ac21e23b624a040a5da780772646

나는 이런 식으로 input() 함수 사용해서 그냥 터미널에 붙여넣게 했다. 어차피 내가 로컬에서 쓰는 프로그램이니 깊게 생각하지 않을 거면 이렇게 해도 될 것 같다.

그리고나서, 이제 이 Authorization Code를 기반으로 실제 사용하게 될 Access Token을 입력받을 차례이다. 먼저 Tistory의 문서를 읽어보면 다음과 같이 나와있다.

Client Id, Client Secret은 기존에 우리가 받은 App Id, Client Key를 이용하면 된다. 아 제발 용어 통일좀 ㅋㅋㅋ...
그리고 Redirect URI도 똑같이 사용하면 되고, code에는 우리가 받은 Authorization Code를 사용하면 된다.

이를 바탕으로 http GET 요청을 하는 함수를 만들어보자. 이때 python은 request라는 모듈을 이용하면 쉽게 HTTP 상의 요청을 수행할 수 있다.

def get_access_token(client_id, client_secret, redirect_uri, code):
    url = "https://www.tistory.com/oauth/access_token"
    params = {
        "client_id": client_id,
        "client_secret": client_secret,
        "redirect_uri": redirect_uri,
        "code": code,
        "grant_type": "authorization_code"
    }

    response = requests.get(url, params=params)
    if response.status_code == 200:
        return response.text.split("=")[1]
    else:
        raise Exception(f"Failed to get access token: {response.text}")

이때 아주 중요한 사항이 하나 있다. 정말 어이없게도, response의 평문(Plain Text)를 뽑아보면, 그냥 Access token이 abcdefghijklmnopqrstuvwxyz 이런 식으로 들어있지 않다. access_token=abcdefghijklmnopqrstuvwxyz 이렇게 들어있다. 앞에 access_token이라는 글자가 포함되어 있다. 내가 이거때문에 Access Token이 유효하지가 않다고 해서 대체 뭐가 문제인지 계속 찾아다녔는데.. 앞에 붙은 쓸데없는 텍스트가 문제였다!

다음부터는 꼭 직접 API 서버에 요청을 보냈을 때 돌아오는 응답을 평문으로 출력해서 직접 확인하도록 하는 습관을 들여야겠다... 내가 생각했던 결과가 아닐 수 있다. 하지만 애초에 공식 문서에서도 그냥 {ACCESS_TOKEN} 이렇게만 반환된다고 말했는데, 이건 너무하긴 했다... 보통 Access token을 요청하면 Access Token만 돌아와야 하는게 아니냐고...

어쩌면 내가 HTTP의 Response에 대해서 정확히 이해를 하지 못해서, 원래 이를 plain text로 바꾸면 이렇게 되는건가 싶기도 하다. 여하튼 이거를 놓쳐가지고 계속 고생하다가, 직접 request나 response 모두 프린트해서 찾는 와중에, access_token=access_token=abcdefg... 이런 식으로 request가 수행되고 있었음을 뒤늦게 발견했다. 그래도 하루만에 발견해서 다행이다.

무엇이 되었든 위 상황에서는 split() 함수를 이용해서 =을 기점으로 나누고, 뒤에 문자열을 리턴하면 될 것이다. 그래서 return response.text.split("=")[1]가 반환값이 된다.

한편 만약 http 200 상태가 아니라면 그냥 에러를 일으키도록 했다.

나는 위 함수를 아래와 같이 썼다.

try:
    access_token = tpc.get_access_token(
        client_id=client_id,
        client_secret=secret_key,
        redirect_uri=redirect_uri,
        code=code
    )
except Exception as e:
    print(e)
    exit(1)

다 됐는데, 여기서 하나만 더 추가하자. 사실 Authorization Code는 매번 발급받을 때마다 달라지는데, Access Token은 사실 달라지지 않는다. 그래서 사용자가 Access Token을 환경 변수에 추가할 수 있도록 하자. 그래서 환경 변수에 등록된 Access Token이 있는지 확인하고, 없다면 위와 같은 과정을 거쳐서 Access Token을 얻도록 하자.

access_token: str = os.getenv('ACCESS_TOKEN')
if access_token == '' or access_token is None:
    client_id: str = os.getenv('CLIENT_ID')
    redirect_uri: str = os.getenv('REDIRECT_URI')

    auth_url: str = tpc.get_authorization_url(
        client_id=client_id,
        redirect_uri=redirect_uri,
    )

    print(f'Move to the following URL and authorize the app: {auth_url}')
    webbrowser.open(auth_url)
    code = input('Input provided code: ').strip()

    secret_key: str = os.getenv('SECRET_KEY')

    try:
        access_token = tpc.get_access_token(
            client_id=client_id,
            client_secret=secret_key,
            redirect_uri=redirect_uri,
            code=code
        )
    except Exception as e:
        print(e)
        exit(1)
print(f'Access token: {access_token}')

위와 같은 과정을 거쳐서 Access Token을 얻도록 했다. 먼저 환경 변수에 등록된 ACCESS_TOKEN이 있는지 확인하고, 없다면 위에서 설명한 과정을 거쳐서 Access Token을 받는다.

한편 여기서 더 개선하려면, 등록된 Access Token이 유효한 지 미리 http Request를 보내서 유효한 상태가 돌아오는지 확인하고, 유효하지 않다면 다시 Access Token을 받는 방식으로 개선하는 게 좋을 것이다. 또한 사용자가 직접 .env 파일 열어서 수정할 필요 없이 자동으로 새로 생성받은 Access Token을 쓰도록 할 수도 있을 것이다. 개선의 가능성은 다양하게 열려 있다. 뭐가 됐든, 이제 tistory의 API를 이용할 수 있게 됐으니, 이를 통해서 본격적으로 우리가 만들려는 기능을 구현해보자.

해당 블로그의 특정 기간 동안 작성된 글 목록을 찾아내는 함수

티스토리의 글 목록 문서를 읽어보면, 다음 uri로 요청을 보내야 함을 알 수 있다.

GET https://www.tistory.com/apis/post/list?
  access_token={access-token}
  &output={output-type}
  &blogName={blog-name}
  &page={page-number}
  • blogName: Blog Name
  • page: 불러올 페이지 번호

응답 item과 예시를 보자.

응답 item:

  • url: 티스토리 기본 url
  • secondaryUrl: 독립도메인 url
  • page: 현재 페이지
  • count: 페이지의 글 개수
  • totalCount: 전체 글 수
  • posts: 글 리스트
    • id: 글 ID
    • title: 글 제목
    • postUrl: 글 대표 주소
    • visibility: 글 공개 단계 (0: 비공개, 15: 보호, 20: 발행)
    • categoryId: 카테고리 ID
    • comments: 댓글 수
    • trackbacks: 트랙백 수
    • date: YYYY-mm-dd HH:MM:SS

응답 예시(json):

{
  "tistory": {
    "status": "200",
    "item": {
      "url": "http://oauth-test.tistory.com",
      "secondaryUrl": "",
      "page": "1",
      "count": "10",
      "totalCount": "181",
      "posts": [
        {
          "id": "201",
          "title": "테스트 입니다.",
          "postUrl": "http://oauth-test.tistory.com/201",
          "visibility": "0",
          "categoryId": "0",
          "comments": "0",
          "trackbacks": "0",
          "date": "2018-06-01 17:54:28"
        },
        ...
      ]
    }
  }
}

좋다. 우리가 원하는 것은 특정 기간 동안 작성된 게시글의 목록이니, "posts" 속성의 "date"를 체크하면 된다. 이때 게시글은 최근 작성된 게시글 순서대로 반환된다. 그러니 모든 포스트들을 다 돌 필요는 없고, 설정한 기간을 벗어나면 그때부터 탐색을 종료하면 된다. 한편 눈 여겨보아야 할 점은 page number이다. 블로그 게시물이 많다면 모든 페이지를 불러오지는 않는 것 같다. 그래서 page number를 하나씩 증가시키며 모든 포스트들을 불러올 수 있도록 순회해야 한다. 예를 들어 모든 페이지의 "count"가 10이고 "totalCount"가 181이면, 총 19번을 순회해야 할까?

그럴 수도 있겠지만, 앞서 말했듯이 게시글은 시간 순서대로 정렬되고, 그래서 특정 기간을 넘어간다면 그냥 탐색을 종료해버리면 된다.

혹시 이전에 작성된 게시글을 최근에 수정한다면? 이건 솔직히 확인은 안 해봤는데, 그래도 티스토리 이용 경험상 게시글을 수정할 때 최초 발행일을 그대로 유지할 수가 있고, 또 수정한 시점으로 다시 올리게 되면 그건 새로운 게시물로 취급되든, 하여튼 순서가 위로 올라갈 것이다. 그래서 크게 신경 안 써도 될 것 같다.

여하튼 이를 코드로 구현해보자.

def fetch_blog_posts_in_period(
        blog_name: str,
        start_date: datetime,
        end_date: datetime,
        access_token: str) -> list[str]:
    posts: list[str] = [] # 함수가 반환할 결과값, post의 URL 주소 리스트
    page_number: int = 1 # 1페이지부터 순서대로 순회

    while True:
        api_url: str = f"https://www.tistory.com/apis/post/list"
        params = {
            'access_token': access_token,
            'output': 'json',
            'blogName': blog_name,
            'page': page_number,
        }

        # Fetch the JSON data from the API
        response = requests.get(api_url, params=params).json()

        status = response['tistory']['status']
        if status != "200":
            error_msg = f"Status: {status} Error message: {response['tistory'].get('error_message', 'No error message')}"
            raise Exception(error_msg)

        # Extract the posts
        if 'posts' not in response['tistory']['item']:
            return posts  # Return if there are no more posts
        for post in response['tistory']['item']['posts']:
            post_date: datetime = datetime.strptime(post['date'], '%Y-%m-%d %H:%M:%S')
            if start_date <= post_date <= end_date:
                posts.append(post['postUrl'])
            elif post_date < start_date:
                return posts  # Return early if we've moved past the date range

        # Increment the page number for the next iteration
        page_number += 1

json 형식으로 바꾸었기에 response의 status를 체크할 때 if response.status_code != 200 이런 식으로 하면 안 된다. 왜냐면 json으로 바꿔서, 200이 아니라 문자열 "200"이 돌아오게 될 것이다. 주의하자.

한편 다른 건 문제가 안 되는데, 하나 주의해야 할 것은 만약 해당 블로그에 모든 게시글이 비밀글이거나 혹은 작성된 게시글이 없다면 어떻게 될까?
정답은 "posts" 속성이 없어진다. 그렇기에 이를 확인하지 않고 "posts" 속성을 조회하려고 한다면, keyError를 겪게 될 것이다. 왜냐면 내가 그랬으니깐...

다행히 이 문제는 간단하게 해결할 수 있다. 두 방법이 있는데, "totalCount"가 0이면 포스트가 없는 것이고, 좀 더 확실하게 하려면 해당 페이지 내에 "posts" 속성이 있는지 확인해주면 된다. 그래서 아래 if 문이 들어가 있는 것이다.

if 'posts' not in response['tistory']['item']:
    return posts  # Return if there are no more posts

추가
한편 위에서는 모든 게시글이 비밀글(비공개)일 때 posts라는 속성이 없어진다고 했는데, 사실 이건 맞지 않는 행동같다. 왜냐하면 애초에 응답 예시를 보면, visibility 속성이 존재하기 때문이다.

  • posts: 글 리스트
    • id: 글 ID
    • title: 글 제목
    • postUrl: 글 대표 주소
    • visibility: 글 공개 단계 (0: 비공개, 15: 보호, 20: 발행)

즉 비공개 글이든 보호글이든 발행글이든 Response에 포함이 되어야 하는게 아닌가 싶었다. 혹은 비공개 글은 집계 자체에도 포함되지 않는게 맞는 것 같기도 하지만.. 어쨌든 비공개 글이여도 posts 글 리스트는 빈 배열이라도 포함되어야 하지 않나 싶은게 내 생각이다. 혹시 이 문제에 대해서 자세히 아시는 분이 있다면 알려주시면 정말 감사하겠다.

한편 만약 posts가 있는 것이 확인된다면, 각 posts의 post들을 순회해가며 기간에 맞는 게시글만 결과값에 저장해주면 된다.

for post in response['tistory']['item']['posts']:
    post_date: datetime = datetime.strptime(post['date'], '%Y-%m-%d %H:%M:%S')
    if start_date <= post_date <= end_date:
        posts.append(post['postUrl'])
    elif post_date < start_date:
        return posts  # Return early if we've moved past the date range

# Increment the page number for the next iteration
page_number += 1

참고로 파이썬은 2중 비교 연산자가 지원되기에 if start_date <= post_date <= end_date 이런 식으로 써도 무방하다. 그런데 다른 대부분의 언어들은 이런 식으로 썼다간 정말 큰일난다는 것을 기억하자. 다른 언어들은 if start_date <= post_date and post_date <= end_date 이런 식으로 작성되어야 한다. 실수하기 쉬운 부분이니 정말 주의해야 한다.

그리고 만약 post_date가 시작 기간보다 과거에 있으면 탐색을 종료해도 된다. 그리고 페이지를 모두 순회했는데, 아직도 기간 내 작성된 포스트들이 남아있다면, page_number를 하나 올려주는 것도 있지 말자.

마무리 - 모든 블로그들의 게시글 리스트들을 받아보자.

이제 위에서 구현한 함수를 이용하기만 하면 된다.

def fetch_blog_posts_in_period_all(
        blog_name_list: list[str],
        start_date: datetime,
        end_date: datetime,
        access_token: str) -> list[dict[str, Union[list[str], str]]]:
    all_posts: list[dict[str, Union[list[str], str]]] = []

    for blog_name in blog_name_list:
        try:
            user_posts = fetch_blog_posts_in_period(blog_name, start_date, end_date, access_token)
            all_posts.append({
                "blog_name": blog_name,
                "posts": user_posts,
            })
        except Exception as e:
            print(f"Failed to fetch posts from {blog_name}: {e}")

    return all_posts

그냥 블로그 리스트를 인자로 받아서, 순회하면서 차례로 fetch_blog_posts_in_period 함수를 호출하면 된다.

blog_posts = tpc.fetch_blog_posts_in_period_all(blog_name_list, start_date, end_date, access_token)
for blog in blog_posts:
    print(f"Blog: {blog['blog_name']} : {len(blog['posts'])}")
    for post in blog['posts']:
        print(f"  Post: {post}")

main 함수에서는 위와 같이 실행하면 될 것 같다.

결과를 확인해보자.

성공했다. 잔뜩 기쁘다.

개선점

사실 내가 혼자 쓰려는 프로그램이라서 이정도만 해도 충분하긴 한데, 추후에 동아리 혹은 타 동아리에서도 사용할 수 있게끔 만들려면 더 개선할 점은 많긴 하다.

일단 현재는 리디렉션 페이지가 내 블로그 주소로 되어 있는데, 사실 이러면 안 되긴 하다. localHost로 열든지 해서 내가 정해놓은 페이지로 이동시키고, 해당 페이지에 간단히 html이라도 올려서, 위에 주소에서 code를 복붙해야 함을 설명해주는 안내서가 있으면 좋다.

또한 Access Token 역시 현재는 console 창에 복붙하는데, 이렇게 내가 창을 띄운다면 해당 창에서 직접 code를 입력할 수 있는 폼을 만들어도 될 것이다. 그런데 이러면 아마 보안을 생각해야 할 수도 있어서 잘 모르겠다. 그리고 여기서부터 flask나 django를 써야 한다. 근데 써본 적 없다. 언젠가 미래에 나에게 맞긴다. 혹은 누군가가 해줬으면 좋겠다.

Tistory API 사용 후기

사실 이건 내 개인적인 감상일 수도 있지만, 솔직히 tistory api 문서는 좀 불친절한 것 같기도, 한국어로 쓰여있어서 엄청 친절한 것 같기도 하다. 일단 아래 글은 이해가 되면서도 공식 문서에 이렇게 써도 되나 싶긴 했다.

그냥 검색해보라니...