트위터로 trpg/게임용 봇을 만들어보자 [4] 구글 시트로 실시간 데이터 관리하기

오터넛·2022년 5월 7일
0

※ 모든 글은 다음의 목차로 이루어져있습니다.
1. 코딩에 대한 기본적인 지식이 있는 분들/혹은 상세한 설명 없이 단지 코드를 사용하기 만하고 싶은 분들을 위한 간략한 요약문
2. 코딩에 대한 아무런 지식이 없는 초보자를 위한 입문글

💡 이 글에서 다루는 프로젝트 파일

src/dataProcessors/from_google_spread_sheet.py
src/tweetBot/update_tweet.py

이전 글과 마찬가지로 구글 스프레드 시트를 사용합니다. 공개된 배포 시트는 아래와 같습니다.

배포용 공개시트 : https://docs.google.com/spreadsheets/d/15HMKanZCVymE3XsnkhjgYZ_ddQTmfZRTrclwD3S9Cts/edit#gid=1956526250

구글 시트로부터 플레이어들의 데이터를 가져오고 플레이어의 행동에 따라 변경되는 값들을 다시 저장하는 기능에 대해 설명하고 있습니다.

예시에서 제공되는 플레이어의 데이터는 자유롭게 삭제, 추가하여 사용할 수 있습니다.

단, 코드에서 제공되는 기본 키워드 기능 중 아래의 것들은 플레이어 데이터에서 특정한 값을 가져와 사용하므로 키워드를 사용하려면 코드를 수정하거나 데이터를 유지하셔야합니다.

[사냥] - [스테미나] 사용
[낚시] - [떡밥] 사용
[장비뽑기] - [크리스탈] 사용, [B급장비개수][C급장비개수] 업데이트
[일괄판매] - [B급장비개수][C급장비개수] [골드] 업데이트

1. 빠른 실행을 위한 간단한 설명

💡 시트에 새로운 플레이어 특성값을 추가해도 코드에서는 특별히 바꿀 내용이 없습니다. 따라서 본 글에서는 사용시 주의사항을 위주로 다룹니다.

  1. 플레이어 데이터 시트는 시트의 가장 첫 열 'id' 로 플레이어를 판별합니다. 봇을 사용하여 해당 시트와 연동을 하고 싶을 경우 id열에 공백이 있으면 코드는 치명적인 오류를 일으킬 수 있습니다.

  2. 유저 id 는 @를 제외하고 작성해주세요

  3. 유저의 이름은 러닝 중 변경되어도 문제가 없으나, 아이디의 경우 변경시 시트도 함께 수정되어야합니다.

  4. 시트에 존재하지 않는 계정은 봇을 사용할 수 없도록 프로그래밍되어있습니다. 커뮤 러닝용 봇이다보니 관리 측면에서 이와같이 코드를 작성하였으며 해당 기능을 제거하고 싶으실 경우 디엠이나 이메일로 연락부탁드립니다!

  5. 새롭게 추가된 특성값은 update_tweet.py/check_mentions에서 다음의 방식으로 접근 가능합니다.
    예를 들어, 새로운 특성값으로 플레이어의 HP를 추가했다면 아래의 코드로 접근 가능합니다.

sheet_data["플레이어"][user_id]["HP"]
  1. 마찬가지로 플레이어의 HP를 변경할 경우 다음의 코드를 적어주시면 트윗 답변후 자동으로 구글 시트에 업데이트합니다. 이외의 특별한 코드는 필요없습니다.
    ex) user_id를 가지는 특저어 플레이어의 hp 를 100으로 정정
sheet_data["플레이어"][user_id]["HP"] = 100

2. 입문자를 위한 자세한 설명

플레이어 데이터 처리에서는 지난 글에서 배웠던 데이터 불러오기 이외에 아래의 기능이 추가됩니다.

  1. 각 데이터를 'user_id'에 따라 분류하기
  2. 새로이 답변할 때마다 google_sheet에서 새로운 데이터를 불러오기
  3. 변경된 데이터를 google_sheet에 저장하기

1. 각 데이터를 'user_id'에 따라 분류하기

우리는 지난 글에서 시트로부터 데이터를 받아오면 데이터가 dict 형의 list로 반환된다는 사실을 배웠습니다. 하지만 플레이어 데이터를 처리하기 위해 충분한 방법이 아닙니다.

예를들어, 아래의 코드로 플레이어 시트를 요리 시트와 같은 방식으로 불러왔다고 합시다.

google_sheet = self.client.open("bot-data-example")

# Extract and print all of the values
sheet = google_sheet.worksheet("플레이어 데이터")
raw_data = sheet.get_all_records()

그럼 다음의 시트가

아래의 형태로 저장됩니다.

0: {id: example_1, 닉네임: 플레이어A, 크리스탈: 15000, 스테미나: 200, 떡밥: 10, B급장비개수: 0, C급장비개수: 1, 골드: 100000}
1: {id: example_2, 닉네임: 플레이어B, 크리스탈: 15000, 스테미나: 200, 떡밥: 10, B급장비개수: 0, C급장비개수: 1, 골드: 100000}
2: {id: example_3, 닉네임: 플레이어C, 크리스탈: 15000, 스테미나: 200, 떡밥: 10, B급장비개수: 0, C급장비개수: 1, 골드: 100000}

여기서 무슨 문제가 있을까요?

예를들어 플레이어A가 떡밥을 1개 사용해서 [낚시]를 한다고 가정해봅시다. 그렇다면 우리는 플레이어A의 데이터에 접근해야합니다.

[요리]에서는 랜덤한 음식을 가져오는 것이 목적이었기 때문에 어떤 음식이 몇번인지를 알 필요가 없었습니다. 하지만 위의 데이터에서는 플레이어A의 데이터에 접근하기 위해서 우리는 플레이어A가 0번에 저장되어있단 사실을 알아야합니다. 그리고 그 코드는 아래와 같습니다.

raw_data[0]["떡밥"]

이 사실을 봇이 어떻게 알게 할 건가요? 봇이 알고 있는건 트윗을 보낸 사람의 id와 닉네임, 그리고 데이터 덩어리입니다. 이들 사이의 관계 따위는 모릅니다.

그래서 필요한 것이 각각의 데이터에 '이름'을 붙여주는 작업입니다.

우리는 dictionary라는 것을 배웠습니다. 특정한 '이름'에 '데이터'를 저장하는 데이터 구조입니다. 이것을 이용하면 우리는 '특정 유저'의 '데이터'라는 자료를 다음과 같이 쓸 수 있습니다.

example_1: {id: example_1, 닉네임: 플레이어A, 크리스탈: 15000, 스테미나: 200, 떡밥: 10, B급장비개수: 0, C급장비개수: 1, 골드: 100000}
example_2: {id: example_2, 닉네임: 플레이어B, 크리스탈: 15000, 스테미나: 200, 떡밥: 10, B급장비개수: 0, C급장비개수: 1, 골드: 100000}
example_3: {id: example_3, 닉네임: 플레이어C, 크리스탈: 15000, 스테미나: 200, 떡밥: 10, B급장비개수: 0, C급장비개수: 1, 골드: 100000}

이제 봇은 example_1 의 떡밥의 개수를 가져올 때 다음과 같이 코드를 쓸 수 있습니다.

raw_data[user_id]["떡밥"]

이와 같은 처리를 해주기 위한 코드는 아래와 같습니다.

user_data_dict = {}
for user in raw_data:
    user_data_dict.update(
          {user["id"]: user}
    )

첫 줄에서 우리는 플레이어 데이터를 저장할 새로운 저장소인 user_data_dict 라는 dictionay 를 만들었습니다. 해당 줄은 dictionary를 만드는 코드이니 기억해두시면 여러모로 유용합니다.

그리고 for 문을 통해 raw_data 값을 하나하나 확인하고, user_data_dict에 유저 데이터를 유저 id값으로 저장합니다.

이렇게 dictionay 값을 저장할 때는 update 라는 dictionary의 함수를 사용합니다. 또한 update 의 함수 파라미터는 dictionay 값이어야합니다. dictionary의 실제 표현식은 아래와 같습니다.

{ "key": value }

위의 코드는 해당 표현식을 지켜 함수의 괄호안에 값을 넘겨주고 있습니다.

이에 대해 더 자세히 공부하고 싶으시다면 python 의 dictionarylist 자료형에 대해 공부하시길 추천드립니다.

💡요약

  • 플레이어 데이터와 같이 특정한 값에 접근해야하는 경우에는 list가 아니라 dictionary 구조형으로 변경하는 것이 좋다.
  • for 문과 dictionary 의 update 함수를 통해 list> dictionary로 데이터 구조를 변경할 수 있다.

3. 새로이 답변할 때마다 google_sheet에서 새로운 데이터를 불러오기

지난 페이지에서는 구글 시트로부터 데이터를 불러오는 방법을 배웠습니다. 하지만 해당 코드는 일회성코드입니다. 무슨 뜻이냐면, 프로그램을 처음으로 동작시켰을 때 한번만 데이터를 불러온다는 뜻입니다. 이후에 시트에 수정된 내용을 반영하고 싶다면 봇을 재가동시켜야합니다.

(요청이 많을 시 후에 하루에 n번 자동 업데이트 코드를 추가할 예정이긴합니다.)

그러니 플레이어 데이터와 같이 실시간으로 변하는 값에 대응하기 위해서는 멘션에 답할 때마다 플레이어 데이터를 불러온다는 코드가 추가되어야 합니다.

그렇다면 이 코드가 어디에 들어가야할까요?

정답은 봇이 멘션을 생성할 때마다입니다. 그리고 프로젝트에서 그 위치는 아래와 같습니다.

 def check_mentions(self, api, keywords, since_id, sheet_data):
        latest_id = since_id
        received_tweets = []
        for tweet in tweepy.Cursor(api.mentions_timeline, since_id=since_id).items():
            if tweet.in_reply_to_status_id is not None:
                continue
            new_tweet = Tweet(id=tweet.id, user=tweet.user, text=tweet.text)
            received_tweets.append(new_tweet)

        for tweet in reversed(received_tweets):
            self.google_api.update_user_sheet_data_from_google(sheet_data)

가장 아랫줄에 self.google_api.update_user_sheet_data_from_google 가 보이시나요?

해당 줄에서는 멘션을 보내기 전에 구글 시트로부터 데이터를 불러오는 일을 수행하며 src/dataProcessors/from_google_spread_sheet에 아래와 같이 구현되어있습니다.

    def update_user_sheet_data_from_google(self, sheet_data: dict):
        user_raw_data = self.get_all_data_from_sheet(SHEET_NAME, "플레이어 데이터")
        user_data = DataProcessingService().get_user_data_dict(user_raw_data)

        sheet_data.update({"플레이어": user_data})

아마

for tweet in tweepy.Cursor(api.mentions_timeline, since_id=since_id).items():

줄은 받은 멘션 불러오기에서 이야기했으니 익숙할 겁니다. 하지만 그 아래에

for tweet in reversed(received_tweets):

은 이해하기 어려울 수도 있습니다. 해당 코드가 존재하는 이유는 사실 twitter api 의 mentions_timeline 함수가 가지는 단점 때문입니다.

mentions_timeline 함수는 타임라인에 특정 멘션 이후의 멘션을 모두 보여주지만, 시간 순이 아니라 시간 역순으로 보내줍니다. 시간 역순으로 답을 주다가 코드에 문제가 생길 경우 아직 답변을 주지 않은 멘션에 대한 계산이 복잡해집니다.

💡 중요하지 않은 사담
tweepy 는 api 중에서 상당히 불친절한 api에 속합니다. 보통의 api 들은 위와 같이 특정 데이터 리스트를 뽑아줄 경우 시간 역순으로 받을 것인지 혹은 시간순으로 받을 것인지를 선택하게 해줍니다. 하지만 tweepy는 해당 기능을 제공하지 않으므로 시간순으로 멘션을 처리하고 싶다면 위와 같은 처리가 필요합니다. 개인적인 확인 결과 v2에서도 위의 기능은 제공해주지 않고 있습니다.

3. 변경된 데이터를 google_sheet에 저장하기

마지막으로, 데이터를 변경하고 이를 저장하는 과정에 대해 알아보겠습니다.

이번에는 [낚시] 키워드를 예시로 들어보겠습니다.

user_bites = sheet_data["플레이어"][user_id]["떡밥"]
fishing_comments = sheet_data["코멘트"]["낚시_멘트"]

if user_bites >= REQUIRED_BITE:
        fishing_result = self.activities.activity_result(sheet_data["낚시"], fishing_comments)
        reply_image = fishing_result["image_name"]
        reply_comment = "@%s" % user_id + fishing_result["comment"]

		# update user data
        sheet_data["플레이어"][user_id]["떡밥"] -= REQUIRED_BITE
        
else:
        reply_comment = "@%s" % user_id + "떡밥이 부족하거나 없는 유저명입니다. 상점에서 떡밥 구입하세요."

첫번째 줄에서는 1에서 이야기한 것처럼 플레이어의 user_id를 통해 떡밥을 가져옵니다.

그리고 다음 if 문을 통해 떡밥이 있는지를 확인합니다. REQUIED_BITE는 제가 숫자 1을 저장해놓은 이름입니다. 그냥 숫자1과 동일하다고 생각하시면 됩니다.

다음으로 activity 파일에서 activity_result 라는 함수를 이용하여 낚시 결과를 출력한 후, 이들을 각각 reply_image 와 reply_comment 에 저장하고 있습니다.

activity_result는 아래와 같이 구현되어있는데 여기서 배운 지식들을 활용하면 어렵지 않게 해석 가능할 것입니다.


    def activity_result(self, activity_data, comment_list):
        activity_comment = comment_list[randint(0, len(comment_list) - 1)]

        # 전설 1% 초희귀 10% 희귀 30% 평범 40% 꽝 20%
        value = randint(1, 101)
        if value < 20:
            monster_list = activity_data["꽝"]
        elif value < 60:
            monster_list = activity_data["평범"]
        elif value < 90:
            monster_list = activity_data["희귀"]
        elif value < 99:
            monster_list = activity_data["초희귀"]
        else:
            monster_list = activity_data["전설"]

        result_monster = monster_list[randint(0, len(monster_list) - 1)]
        result_comment = f'{activity_comment}\n . \n . \n . \n [{monster["등급"]}]{monster["이름"]}\n{monster["아이템_설명"]}\n\n'
        
        if monster["추천_레시피"]:
            result_comment += f'추천 레시피: {monster["추천_레시피"]}'
            
        image_name = result_monster["이미지"]

        return {"image_name": image_name, "comment": result_comment}

낚시 결과를 모두 가져왔으면 이제 플레이어 데이터에서 유저가 사용한 떡밥만큼 떡밥을 차감해줘야합니다. 그에 대한 코드가 아래의 코드입니다.

sheet_data["플레이어"][user_id]["떡밥"] -= REQUIRED_BITE

-= 는 왼쪽 값에서 오른쪽 값만큼을 빼서 새로 저장한단 뜻입니다.

예를 들어 apples = 10 이라는 값이 있을 때, 다음의 코드를 실행하면

apples=10
apples-=1

apples에는 9가 저장됩니다.

마지막으로 이렇게 업데이트한 값을 구글 스프레드 시트에 저장해야합니다. 이때, 구글 스프레드 시트는 데이터를 업데이트 할 때 2차 배열로 값을 받습니다.

💡 2차 배열이 무엇인가요?
2차 배열이란 list 안에 list가 들어있는 형태입니다. 좀더 간단히 설명하면 우리가 맨처음에 받았던 list 안에 dict가 들어있던 형태에서 dict의 key 값을 모두 제거하고 value 들만으로 새로이 값을 추가해준 데이터만을 받을 수 있다고 이해하시면 됩니다.

이를 위한 코드는 아래와 같습니다.

user_data_list = []
for _, user_object in sheet_data["플레이어"].items():
      user_data_list.append([data for data in user_object.values()])

google_sheet = self.client.open(SHEET_NAME)
sheet = google_sheet.worksheet("플레이어 데이터")
sheet.update("A2", user_data_list)

먼저 구글 시트에 보내줄 데이터 상자인 user_data_list를 만듭니다.
그리고 for 문을 통해 sheet_data["플레이어"] 즉, 우리가 아까 만들어두었던 플레이어의 dict 목록

example_1: {id: example_1, 닉네임: 플레이어A, 크리스탈: 15000, 스테미나: 200, 떡밥: 10, B급장비개수: 0, C급장비개수: 1, 골드: 100000}
example_2: {id: example_2, 닉네임: 플레이어B, 크리스탈: 15000, 스테미나: 200, 떡밥: 10, B급장비개수: 0, C급장비개수: 1, 골드: 100000}
example_3: {id: example_3, 닉네임: 플레이어C, 크리스탈: 15000, 스테미나: 200, 떡밥: 10, B급장비개수: 0, C급장비개수: 1, 골드: 100000}

중에서 key 값을 제외한 value들만을 user_object 라는 이름으로 가져옵니다.
(이에 대한 문법은 python for loop with index 에서 자세히 확인할 수 있습니다.)

이때 값들은 다시 dictionary 구조형이므로, 이들 중에서 value 들만을 가져오는 함수인 values()를 통해 값을 뽑아낸 이후에 이들을 user_data_list에 추가해줍니다.

해당 코드는 좀 복잡하게 생겼는데, python list generator 문서를 확인하시면 좀더 이해하기 쉽습니다!

이렇게 만들어진 데이터를 저장하기 위해 저장할 목적지를 배운 방식대로 접근하고, 마지막으로 sheet 에서 update() 함수를 실행시킵니다.

update 함수는 데이터의 시작점과 데이터를 파라미터로 받습니다. 저희는 데이터에서 가장 윗줄 header를 제외하고 넣을 것이므로 A2를 지정했습니다.

이렇게 하면 봇은 낚시가 끝난 후 떡밥의 개수를 하나 줄여서 시트에 새롭게 기록합니다.

💡요약

  • -= 혹은 += 등의 기호를 통해 변수에 새로운 값을 넣어준다.
  • 새롭게 저장된 값을 구글 시트에 저장하기 위해 list(dict) 자료형에서 list(list) 형태로 변환해준다.
  • google sheet api의 update 함수를 통해 데이터를 저장할 최상단의 가장 왼쪽 셀의 이름을 적고, 새롭게 생성한 데이터를 넘겨준다.

그리고 축하드립니다. 이것으로 모든 기능을 구현하는데 성공했습니다!

3. 마치며 ...

구글 스프레드 시트의 데이터 가공 부분은 데이터 구조와 밀접하게 관련이 있다보니 처음접하실 경우 당연히 이해하기 힘듭니다. 좀더 쉽게 설명하고 싶었는데 제 능력의 한계네요.

기회가 된다면 관련한 주제들로 좀더 다양한 이야기들을 전달할 수 있으면 좋겠습니다.

이를 위해서라도 여러분의 소중한 의견을 언제나 받고 있습니다.

읽으면서 너무 어렵다! 이부분은 더 알고 싶다! 하는 부분은 디엠이나 이메일로 남겨주세요. 바로바로는 어려울 수 있지만 시간이 날때마다 답변을 드리고, 특히 많은 질문들을 정리하여 블로그 글 형태로 추가할 예정입니다.

여기까지 오시느라 수고많으셨습니다!

프로그래밍은 처음 접하면 어려운 이야기지만 하다보면 글을 적어내려가는 과정과 유사합니다.

여러분의 즐거운 취미생활과 프로그래밍 입문을 응원합니다.😎

profile
당신의 친절한 오타쿠

0개의 댓글