feat [geek-news-bot]: GeekNews RSS 기반 프론트엔드 뉴스 큐레이션 슬랙봇 추가#29
feat [geek-news-bot]: GeekNews RSS 기반 프론트엔드 뉴스 큐레이션 슬랙봇 추가#29
Conversation
| @@ -1,59 +1,2 @@ | |||
| import type { ChatPostMessageArguments } from "@slack/web-api"; | |||
There was a problem hiding this comment.
이 베럴파일의 존재이유가 뭔가요? client.ts도 따로 export하고, thread.ts도 따로 export해주는거 같아서요!
여기서 types랑 redis만 관리하는 이유가 있을까요?
| }; | ||
| } | ||
|
|
||
| export const createSlackThread = async (id: string, _message: SlackThreadMessage) => { |
There was a problem hiding this comment.
보통 언더바는 안쓰는 값이라는 암묵적인 뜻이 있는 걸로 아는데, _message에 언더바가 붙은 이유는 뭔가여?
| import type { SlackThreadData } from "./types"; | ||
|
|
||
| function toChatPostMessageArgs({ channel, message }: SlackThreadMessage) { | ||
| if (message.blocks !== undefined) { |
There was a problem hiding this comment.
제가 머리가 나빠서 그런가... message의 blocks 값이 undefined가 아니라는건 어떤 의미인가요?
blocks가 의미하는게 뭔지 주석으로 써놓는건 어떨까싶네요
| channel: response.channel ?? _message.channel, | ||
| thread_ts: response.ts ?? "", | ||
| }; | ||
| await setSlackThreadData(id, data, _message.ex ? { ex: _message.ex } : undefined); |
There was a problem hiding this comment.
redis instance에 저장하고 끝인가요?
그 후에 redis에서 캐싱해오기 위한 로직인지 궁금합니다. just wonder
| expect(item.title).toBeTruthy(); | ||
| expect(item.link).toBeTruthy(); |
There was a problem hiding this comment.
item에 id,title,link,pubDate,content가 전부 return되는데, title과 link만 테스트하신 기준이 있으신가요?
나머지 필드도 테스트하거나, 이 두개만 검증하는 이유가 주석에 있음 좋을 것 같아요.
|
|
||
| expect(filtered.length).toBeGreaterThan(0); | ||
|
|
||
| await sendSlackNotification(filtered); |
There was a problem hiding this comment.
이거 sendSlackNotificaton 모킹 안해도 되나요???
| it.skipIf(!hasValidGoogleKey)( | ||
| "AI 필터링이 관련 뉴스를 올바르게 분류한다", | ||
| async () => { | ||
| const filtered = await filterFrontendNews(mockNewsItems); |
There was a problem hiding this comment.
음.. 뭔가 제미나이 무료버전이면, 항상 mock-1,3,5를 선택한다는 보장이 없을지도 ㅋㅋㅋㅋ
| export const runNewsJob = async () => { | ||
| console.log(`[${new Date().toISOString()}] 긱뉴스 패치 시작`); | ||
|
|
||
| const allNews = await fetchGeekNews(); | ||
| console.log(`긱뉴스 RSS에서 ${allNews.length}건의 뉴스를 패치했습니다`); | ||
|
|
||
| const state = await loadStates(); | ||
| const recentIdSet = new Set(state.recentIds); | ||
|
|
||
| const newItems = allNews | ||
| .filter((item) => { | ||
| const itemTs = toTimestamp(item.pubDate); | ||
|
|
||
| /** | ||
| * 타임스탬프 기반 중복 필터링 | ||
| * - lastPublishedAt보다 오래된 뉴스는 이미 처리된 것으로 간주 | ||
| * - lastPublishedAt와 동일한 타임스탬프에 발행된 뉴스는 recentIds로 중복 체크 | ||
| */ | ||
| if (state.lastPublishedAt && itemTs <= state.lastPublishedAt) { | ||
| if (itemTs === state.lastPublishedAt) { | ||
| return !recentIdSet.has(item.id); | ||
| } | ||
| return false; | ||
| } | ||
| return true; | ||
| }) | ||
| .slice(0, ENV.maxNewsPerFetch); |
There was a problem hiding this comment.
runNewsJob 함수에게 기대하는 역할이 무엇인가요?
제가 봤을 때는 뭔가 오케스트레이터같은 역할을 기대하신 것 같아요.
그렇다면, 중간에 필터링 해주는 로직은 이 함수의 관심사라고 볼 수 있을까요?
분리해서 적용해보는 것도 좋아보입니다.
const filterNewItems = (
items: GeekNewsItem[],
state: NewsState,
): GeekNewsItem[] => {
const recentIdSet = new Set(state.recentIds);
return items.filter((item) => {
const itemTs = toTimestamp(item.pubDate);
if (!state.lastPublishedAt || itemTs > state.lastPublishedAt) return
true;
if (itemTs === state.lastPublishedAt) return !recentIdSet.has(item.id);
return false;
});
}; 그러면 runNewsJob이 좀 더 기대한 역할에 맞게 오케스트레이터로써의 역할만 할 수 있을 것 같아요.
export const runNewsJob = async () => {
const allNews = await fetchGeekNews();
const state = await loadStates();
const newItems = filterNewItems(allNews, state).slice(0,
ENV.maxNewsPerFetch);
if (newItems.length === 0) return;
const frontendNews = await filterFrontendNews(newItems);
await sendSlackNotification(frontendNews);
await saveStates(어쩌구);
}; There was a problem hiding this comment.
요로케 분리 하니까 흐름이 선언적으로 잘 보이네요 코드가 예뿌다
| }); | ||
|
|
||
| /** 정기 fetch */ | ||
| cron.schedule(ENV.cronSchedule, () => { |
There was a problem hiding this comment.
이 크론의 주체는 무엇인가요? 누군가의 컴퓨터가 켜져있어야만 동작하는건지 궁금합니다!
There was a problem hiding this comment.
아 도커 확인했습니다. 제가 잘 몰라서 그러는데, 도커도 클라우드에 띄우는거 아니면, 노트북 덮었을 때 같이 꺼지는거 아닌가여??
|
|
||
| const buildNewsBlocks = (items: FilteredNewsItem[]): KnownBlock[] => { | ||
| const capped = items.slice(0, MAX_SLACK_ITEMS); | ||
| const blocks: KnownBlock[] = [ |
There was a problem hiding this comment.
아 blocks가 뭔가 슬랙 메세지 형태(?) 같은건가 보네요
jogpfls
left a comment
There was a problem hiding this comment.
너무 어려웠지만.... 코드리뷰하면서 공부가 정말 많이 된 것 같아요 !!!! 무무봇이 아닌 새로운 봇의 등장... 항상 배우고 갑니다!! 👍
| await saveStates(latestTimestamp, allProcessedIds); | ||
|
|
||
| await sendSlackNotification(frontendNews); | ||
| console.log(`[${new Date().toISOString()}] 작업 완료`); | ||
| }; |
There was a problem hiding this comment.
만약 saveStates로 저장까지는 성공했는데 sendSlackNotification은 실패하게 되면 redis에는 이미 처리 완료로 기록되고 다음 실행 때 해당 뉴스들이 새 뉴스로 인식되지 않고 건너뛰게 되면서 이 타이밍에 저장했던 뉴스들은 Slack에 전송되지 않은 채 유실될 것 같은데 이 부분은 어쩔 수 없는 부분일까요?!
|
|
||
| > 분석일: 2026-04-05 | ||
| > 분석 대상: https://news.hada.io/rss/news | ||
| > 목적: 현재 fetch 설정(3시간 주기 / 50건)으로 뉴스 유실 없이 안정적으로 운영 가능한지 검증 |
There was a problem hiding this comment.
geeknews는 여러 출처의 기술 글을 큐레이션해서 제공하는 플랫폼인데 geeknews를 선택하신 이유가 있으실까요? 제일 보편적이여서일까요?! Just 궁금증입니다
| const GEEK_NEWS_RSS_URL = "https://news.hada.io/rss/news"; | ||
|
|
||
| export const fetchGeekNews = async (): Promise<GeekNewsItem[]> => { | ||
| const feed = await parser.parseURL(GEEK_NEWS_RSS_URL); |
There was a problem hiding this comment.
feed 요청이 실패할 경우의 에러 처리는 없어도 괜찮나요 ?!
| export const runNewsJob = async () => { | ||
| console.log(`[${new Date().toISOString()}] 긱뉴스 패치 시작`); | ||
|
|
||
| const allNews = await fetchGeekNews(); | ||
| console.log(`긱뉴스 RSS에서 ${allNews.length}건의 뉴스를 패치했습니다`); | ||
|
|
||
| const state = await loadStates(); | ||
| const recentIdSet = new Set(state.recentIds); | ||
|
|
||
| const newItems = allNews | ||
| .filter((item) => { | ||
| const itemTs = toTimestamp(item.pubDate); | ||
|
|
||
| /** | ||
| * 타임스탬프 기반 중복 필터링 | ||
| * - lastPublishedAt보다 오래된 뉴스는 이미 처리된 것으로 간주 | ||
| * - lastPublishedAt와 동일한 타임스탬프에 발행된 뉴스는 recentIds로 중복 체크 | ||
| */ | ||
| if (state.lastPublishedAt && itemTs <= state.lastPublishedAt) { | ||
| if (itemTs === state.lastPublishedAt) { | ||
| return !recentIdSet.has(item.id); | ||
| } | ||
| return false; | ||
| } | ||
| return true; | ||
| }) | ||
| .slice(0, ENV.maxNewsPerFetch); |
There was a problem hiding this comment.
요로케 분리 하니까 흐름이 선언적으로 잘 보이네요 코드가 예뿌다
작업 내용
Made with Cursor