프로젝트/단기 프로젝트

커리어리 디스코드 봇 제작기

Chipmunks 2024. 4. 17.
728x90

커리어리 디스코드 봇 제작기

 

안녕하세요, 다람쥐입니다.

최근에 병원에 입원을 했는데요.

예상 외로 회복이 빨라서 바로 손이 근질거리더군요.

새벽에 일찍 일어나서 만들만 만한 게 없을까, 고민하던 차에

자주 이용하는 커뮤니티에 커리어리 글을 가져오는 봇을 만들어봤습니다.

 

배경

저는 커리어리를 알고 있었지만, 이용하지는 않았는데요.

전 직장에서의 사수님도, 최근 멘토님도 커리어리를 자주 보시는 것 같아서

나도 봐야 하나~ 싶더라고요.

가까운 사람의 유행에 민감한 편입니다. 😁

 

커리어리 디스코드 봇 제작기 - undefined - 배경
디스코드 커뮤니티로 퍼온 커리어리 게시글

 

커리어리 글의 내용이 대체로 길지 않은 편이라,

'다 가져올까?'도 생각했지만...

메시지 개수가 늘어나기도 하는 단점이 있어요.

 

최근 인기 있는 게시글만 링크로 가져오는 게 메시지 테러(?)가 되지 않을 것 같네요.

따라서 매 주 / 매 달에 한 번 씩 가져오는 크롤링을 생각했습니다.

 

 

설계

우선 주기적으로 실행 시키는 무언가가 필요한데요!

이전에 디스코드로 원티드 채용 봇을 만들었던 경험이 있었기에,

Github Actions Workflow Cron 으로 스케쥴러를 선택했습니다.

 

어차피 Github 에 소스 코드를 올릴 건데,

Github Actions 으로 스케쥴링까지 적용하면

별다른 인프라가 필요 없습니다.

 

디스코드 원티도 채용 봇은, 이전에 어디까지 가져왔었는지를 기억을 했어야 했는데요.

최근 가져온 것 다음의 공고들을 알림으로 알렸습니다.

그 때 사용한 DB는 REST API 형태로 무료로 이용할 수 있는 RESTDB.IO PaaS 을 사용했었어요.

 

restdb.io - Simple online database backend with NoSQL - automatic REST API - low code javascript hooks - dynamic web - MongoDB -

restdb.io lets you create online cloud databases and REST APIs quickly without coding. Development databases have a free plan. Powerful web-based and mobile-friendly data management....

restdb.io

기능상 각 카테고리(백엔드/안드로이드/iOS/웹 등)마다 최근 게시글 번호 하나만 있으면 돼서

Row 크기 제한도 안 걸려서 잘 애용했었습니다.

 

다만, 커리어리는 이전에 어디까지 불러왔는지 기억할 필요는 없었어요.

단순하게 1주일 마다 / 1달 마다 상위 몇 개의 글만 가져오면 됐습니다.

외부 DB 연동 없이 순수 크롤링 / 스케쥴러만으로 설계했습니다.

 

커리어리 디스코드 봇 제작기 - undefined - 설계
아키텍처

 

언어 코틀린 설정 이유는...

참고할 만한 레거시(?)가 코틀린 앱이었기 때문입니다...!

언어적으론 다음 장점이 있어요.

  • Gradle 빌드로 편리한 라이브러리 사용 가능 (gson, fuel)
    • gson : Json 을 데이터 클래스와 연결
    • fuel : 네트워크 통신 편리함
  • 코루틴 지원
  • 간결한 문법

단점은 빌드 → 실행하는 데 시간이 걸린다는 점이 있어요

아무래도 Gradle 다운로드 받는 시간이 큽니다.

1분 30초 ~ 2분 정도 걸리더라고요.

 

실행 횟수도 많지 않고, 빈도도 높지 않아서

개발 편의성을 더 따지기로 했습니다.

만약 기존 코드가 JavaScript 였다면, 당연히 JavaScript 였을 수도!?

 

프로그램 동작 흐름은 다음과 같아요~

 

  1. API 호출하여 크롤링
  2. Discord 전송 형식에 맞게 변환
  3. 디스코드 웹훅(Webhook)으로 전송

 

fun main() = runBlocking {
    val response = getResponse()
    val embeds = mapToEmbeds(response)
    val webHookData = WebHookData(
        username = "커리어리 봇",
        avatar_url = "https://careerly.co.kr/favicon.png",
        allowed_mentions = AllowedMentions(
            parse = listOf("users", "roles")
        ),
        embeds = embeds,
        content = "**< 주간 인기 TOP 10 >**"
    )
    sendWebHooks(webHookData)
}

 

 

아주 간단한 프로그램이라, 코루틴으로 성능상의 이득은 크게 없긴 합니다.

다만 fuel 라이브러리가 기본적으로 코루틴을 사용해서,

코루틴을 자연스레 사용하게 됐습니다.

runBlocking 블록 및 suspend 키워드로 코루틴을 지원했습니다.

 

 

트러블 슈팅

1. 긴 본문은 ... 줄임표로 만들기

커리어리 디스코드 봇 제작기 - undefined - 트러블 슈팅 - 1. 긴 본문은 ... 줄임표로 만들기

 

본문을 다 담기에는 디스코드 메시지의 양이 정해져 있습니다.

목록을 보여주는 게 목적이라, 어느정도 개요만으로 흥미를 돋게 하려고만 했습니다.

 

data class Comment(
	...
    val description: String,
) {
    val truncatedDescription: String
        get() = description.substring(0, minOf(description.length, 200)) + "..."
}

 

kotlin 의 Property Getter 으로 새로운 Property 를 정의했어요.

substring(0, 200) 으로 처음에 했었는데...

200자보다 적으면 오류가 발생하더라고요.

그래서 minOf 으로 200자와 현재 본문의 글자 수 중 적은 걸로 택하도록 변경했습니다.

200자보다 적어도 '...' 가 붙긴 하는데... 사소한 건 넘어가죠. 😅

 

마지막으로 디스코드 메시지로 변환할 때, '...' 으로 줄인 본문이 담기도록 설정했습니다.

fun mapToEmbeds(response: Response): List<Embed> {
    return response.data.map {
		...

        Embed(
            ...
            description = comment.truncatedDescription,
        )
    }
}

 

 

2. 디스코드 한 메시지의 Embed 수는 10개 까지 제한

주간 TOP 10 은 괜찮으나, 한 달 Trend 20 개에서 웹훅 호출이 실패하더라고요!

오류내용을 보니, Embed 가 10개까지밖에 지원한다고 하는데요.

kotlin 의 컬렉션 확장 메소드 중 chunked 메소드가 떠올라서, 이 메소드를 이용했습니다.

 

fun main() = runBlocking {
    val response = getResponse()
    val embeds = mapToEmbeds(response)

    embeds.chunked(MAX_EMBED_SIZE) { chunk ->
        val isFirstChunk = chunk == embeds.chunked(MAX_EMBED_SIZE).first()

        val webHookData = WebHookData(
            ...
            embeds = chunk,
            content = if (isFirstChunk) {
                "**< 커리어리 트렌드 >**\n지난 30일 동안 각 분야에서 반응이 좋았던 게시물을 만나보세요."
            } else {
                ""
            }
        )

        runBlocking {
            sendWebHooks(webHookData)
        }
    }
}

val MAX_EMBED_SIZE = 10

 

chunked 블록으로 개수만큼 chunk 변수로 전달합니다.

chunked 메소드는 개수보다 커야지만 호출이 되더라고요..!

만약 MAX_EMBED_SIZE (10) 보다 같거나 작으면, 블록 호출이 안됩니다. ㅠㅠ

 

첫 메시지에만 어떤 메시지인지 설명을 포함하고, 뒤에는 연속적으로 메시지가 붙도록 했습니다.

 

3. 작은 사이즈의 프로필 이미지 변환

커리어리 디스코드 봇 제작기 - undefined - 트러블 슈팅 - 3. 작은 사이즈의 프로필 이미지 변환

 

작은 사이즈의 프로필 이미지가 아니라면, 위처럼 디스코드에서 불러오지를 않더라고요.

작은 사이즈의 프로필만을 취급하는 저장소가 있는 것 같아, 아래처럼 도메인 주소를 변경했습니다.

마찬가지로 Property 로 만들었습니다.

 

data class UserProfile(
	...
    val imageUrl: String,
) {
	...

    val smallImageUrl: String
        get() = imageUrl.replace("XXX.com", "YYY.com")
}

 

 

3. RSS 를 읽어서 일간 피드 만들기

하루 마다 핫한 글을 가져오려면 어떻게 해야 할까 고민이었어요.

아무래도 유저마다 피드 발행이 다르기도 하고요.

그 답을 RSS 에서 찾았습니다.

 

RSS 를 이용해서 슬랙봇을 이용하는 것 같더라고요~

지금 시각이 4/18 (목) 오전 6시 54분이니깐...

하루 마다 갱신되는 것으로 보였습니다.

<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0">
<channel>
<title>커리어리 | 요즘 개발자 커뮤니티 </title>
...
<lastBuildDate>Wed, 17 Apr 2024 21:51:14 GMT</lastBuildDate>
...

 

1시간 마다 최근 발행된 게 있으면, 디스코드로 보내는 방향도 생각했는데...

restdb.io 등을 이용해야 하다 보니, 단순하게 오전 9시마다 무조건 발행하는 걸로 방향을 잡았습니다.

 

kotlin 에서 rss parser 로 유명한 라이브러리를 찾아봤어요.

prof18.RSSParser 라이브러리가 유명해보여 채택했습니다.

실제로 사용하기도 무척 편리했어요.

 

val rssParser = RssParser()
val rssChannel = rssParser.getRssChannel(RSS_FEED_URL)

 

fun mapToEmbeds(rssChannel: RssChannel): List<Embed> {
    return rssChannel.items.map {
        Embed(
            color = GREEN_COLOR,
            title = it.title ?: "",
            url = it.link ?: "",
            thumbnail = Image(url = it.image),
            description = it.content ?: ""
        )
    }
}

 

RSS 자체가 특정 형식이 있다보니

파싱 + 객체 변환 과정을 직접 안해도 라이브러리단에서 해주더라고요.

무척 편리하게 구현할 수 있었습니다.

 

커리어리 디스코드 봇 제작기 - undefined - 트러블 슈팅 - 3. RSS 를 읽어서 일간 피드 만들기
RSS 일간 피드 디스코드 테스트

 

아쉬운 점은 주간 / 월간 인기 게시글과 다르게 누가 작성했는지는 정보가 없지만,

그래도 이정도면 만족스럽습니다. 😁

 

 

4. Cron 스케쥴링

Github Actions 으로 아래처럼 간편하게 cron 을 설정할 수 있습니다.

 

name: Crawler-Weekly

on:
  schedule:
    - cron : '0 0 * * 1'
  workflow_dispatch:
jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
        with:
          ref: main

      - name: Run
        env:
          DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
        run: ./gradlew run

 

name: Crawler-Trend

on:
  schedule:
    - cron : '0 0 1 * *'
  workflow_dispatch:
jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
        with:
          ref: trend

      - name: Run
        env:
          DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
        run: ./gradlew run

 

name: Crawler-Day

on:
  schedule:
    - cron : '0 0 * * *'
  workflow_dispatch:
jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
        with:
          ref: day

      - name: Run
        env:
          DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
        run: ./gradlew run

 

on: schedule: cron 으로 설정할 수 있습니다.

이 사이트에서 해당 cron 이 무엇을 의미하고, 다음 시작일이 언제인지 알려줍니다.

커리어리 디스코드 봇 제작기 - undefined - 트러블 슈팅 - 4. Cron 스케쥴링

 

UTC 기준이기에, UTC 자정이면 한국 기준으로 오전 9시가 됩니다!

 

브랜치별로 메인 코드를 나눴어서

actions/checkout@v4 으로 브랜치를 이동한 후 실행했습니다.

 

Settings > Actions > General 탭에서 아래처럼

'Read and write permissions' 옵션을 체크해야 Cron 이 실행됩니다.

커리어리 디스코드 봇 제작기 - undefined - 트러블 슈팅 - 4. Cron 스케쥴링

 

 

5. RSS 에 잘못된 값이 들어가 인코딩 오류

커리어리 디스코드 봇 제작기 - undefined - 트러블 슈팅 - 5. RSS 에 잘못된 값이 들어가 인코딩 오류

4/19일자 Cron 이 실패해 원인을 보니 RSS Feed 자체에 잘못된 값이 들어가 파싱을 할 수 없다는 오류가 나왔어요.

이상한 유니코드 값이 포함되어 웹브라우저에서 조차 읽을 수 없다는 오류가 나왔는데요~
외부에 의존하지 않고 프로그램에서 수정하도록 변경했습니다.

    val rssParser = RssParser()
    val rss = REQUEST_URL.httpGet().body.replace("\u001C", "")
    val rssChannel = rssParser.parse(rss)

 

결론

커리어리에서 직접 보거나, 또는 뉴스레터 등으로 보지만

디스코드 봇으로 커뮤니티에 기여를 해봤습니다.

현재 슬랙용으론 있는 것 같으나, 디스코드용으로는 지원을 해주지 않는데요.

나중에 지원해주면, 그 쪽으로 이동하는 게 맞을 것 같네요. ☺️

 

( 혹 문제가 된다면 삭제하겠습니다. )

댓글