커리어리 디스코드 봇 제작기
안녕하세요, 다람쥐입니다.
최근에 병원에 입원을 했는데요.
예상 외로 회복이 빨라서 바로 손이 근질거리더군요.
새벽에 일찍 일어나서 만들만 만한 게 없을까, 고민하던 차에
자주 이용하는 커뮤니티에 커리어리 글을 가져오는 봇을 만들어봤습니다.
배경
저는 커리어리를 알고 있었지만, 이용하지는 않았는데요.
전 직장에서의 사수님도, 최근 멘토님도 커리어리를 자주 보시는 것 같아서
나도 봐야 하나~ 싶더라고요.
가까운 사람의 유행에 민감한 편입니다. 😁
커리어리 글의 내용이 대체로 길지 않은 편이라,
'다 가져올까?'도 생각했지만...
메시지 개수가 늘어나기도 하는 단점이 있어요.
최근 인기 있는 게시글만 링크로 가져오는 게 메시지 테러(?)가 되지 않을 것 같네요.
따라서 매 주 / 매 달에 한 번 씩 가져오는 크롤링을 생각했습니다.
설계
우선 주기적으로 실행 시키는 무언가가 필요한데요!
이전에 디스코드로 원티드 채용 봇을 만들었던 경험이 있었기에,
Github Actions Workflow Cron 으로 스케쥴러를 선택했습니다.
어차피 Github 에 소스 코드를 올릴 건데,
Github Actions 으로 스케쥴링까지 적용하면
별다른 인프라가 필요 없습니다.
디스코드 원티도 채용 봇은, 이전에 어디까지 가져왔었는지를 기억을 했어야 했는데요.
최근 가져온 것 다음의 공고들을 알림으로 알렸습니다.
그 때 사용한 DB는 REST API 형태로 무료로 이용할 수 있는 RESTDB.IO PaaS 을 사용했었어요.
기능상 각 카테고리(백엔드/안드로이드/iOS/웹 등)마다 최근 게시글 번호 하나만 있으면 돼서
Row 크기 제한도 안 걸려서 잘 애용했었습니다.
다만, 커리어리는 이전에 어디까지 불러왔는지 기억할 필요는 없었어요.
단순하게 1주일 마다 / 1달 마다 상위 몇 개의 글만 가져오면 됐습니다.
외부 DB 연동 없이 순수 크롤링 / 스케쥴러만으로 설계했습니다.
언어 코틀린 설정 이유는...
참고할 만한 레거시(?)가 코틀린 앱이었기 때문입니다...!
언어적으론 다음 장점이 있어요.
- Gradle 빌드로 편리한 라이브러리 사용 가능 (gson, fuel)
- gson : Json 을 데이터 클래스와 연결
- fuel : 네트워크 통신 편리함
- 코루틴 지원
- 간결한 문법
단점은 빌드 → 실행하는 데 시간이 걸린다는 점이 있어요
아무래도 Gradle 다운로드 받는 시간이 큽니다.
1분 30초 ~ 2분 정도 걸리더라고요.
실행 횟수도 많지 않고, 빈도도 높지 않아서
개발 편의성을 더 따지기로 했습니다.
만약 기존 코드가 JavaScript 였다면, 당연히 JavaScript 였을 수도!?
프로그램 동작 흐름은 다음과 같아요~
- API 호출하여 크롤링
- Discord 전송 형식에 맞게 변환
- 디스코드 웹훅(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. 긴 본문은 ... 줄임표로 만들기
본문을 다 담기에는 디스코드 메시지의 양이 정해져 있습니다.
목록을 보여주는 게 목적이라, 어느정도 개요만으로 흥미를 돋게 하려고만 했습니다.
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. 작은 사이즈의 프로필 이미지 변환
작은 사이즈의 프로필 이미지가 아니라면, 위처럼 디스코드에서 불러오지를 않더라고요.
작은 사이즈의 프로필만을 취급하는 저장소가 있는 것 같아, 아래처럼 도메인 주소를 변경했습니다.
마찬가지로 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 자체가 특정 형식이 있다보니
파싱 + 객체 변환 과정을 직접 안해도 라이브러리단에서 해주더라고요.
무척 편리하게 구현할 수 있었습니다.
아쉬운 점은 주간 / 월간 인기 게시글과 다르게 누가 작성했는지는 정보가 없지만,
그래도 이정도면 만족스럽습니다. 😁
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 이 무엇을 의미하고, 다음 시작일이 언제인지 알려줍니다.
UTC 기준이기에, UTC 자정이면 한국 기준으로 오전 9시가 됩니다!
브랜치별로 메인 코드를 나눴어서
actions/checkout@v4 으로 브랜치를 이동한 후 실행했습니다.
Settings > Actions > General 탭에서 아래처럼
'Read and write permissions' 옵션을 체크해야 Cron 이 실행됩니다.
5. RSS 에 잘못된 값이 들어가 인코딩 오류
4/19일자 Cron 이 실패해 원인을 보니 RSS Feed 자체에 잘못된 값이 들어가 파싱을 할 수 없다는 오류가 나왔어요.
이상한 유니코드 값이 포함되어 웹브라우저에서 조차 읽을 수 없다는 오류가 나왔는데요~
외부에 의존하지 않고 프로그램에서 수정하도록 변경했습니다.
val rssParser = RssParser()
val rss = REQUEST_URL.httpGet().body.replace("\u001C", "")
val rssChannel = rssParser.parse(rss)
결론
커리어리에서 직접 보거나, 또는 뉴스레터 등으로 보지만
디스코드 봇으로 커뮤니티에 기여를 해봤습니다.
현재 슬랙용으론 있는 것 같으나, 디스코드용으로는 지원을 해주지 않는데요.
나중에 지원해주면, 그 쪽으로 이동하는 게 맞을 것 같네요. ☺️
( 혹 문제가 된다면 삭제하겠습니다. )