Skip to content

feat [geek-news-bot]: GeekNews RSS 기반 프론트엔드 뉴스 큐레이션 슬랙봇 추가#29

Open
wuzoo wants to merge 11 commits intomainfrom
feat/geek-news
Open

feat [geek-news-bot]: GeekNews RSS 기반 프론트엔드 뉴스 큐레이션 슬랙봇 추가#29
wuzoo wants to merge 11 commits intomainfrom
feat/geek-news

Conversation

@wuzoo
Copy link
Copy Markdown
Member

@wuzoo wuzoo commented Apr 5, 2026

작업 내용

  • GeekNews RSS 피드를 주기적으로 수집하고, Gemini AI로 프론트엔드 관련 뉴스를 필터링하여 Slack 채널에 자동 전송하는 봇 구현
  • node-cron 기반 정기 스케줄링과 Upstash Redis를 활용한 중복 방지 상태 관리 구성
  • pubDate 문자열 비교를 숫자 타임스탬프 비교로 전환하여 날짜 필터링 정확도 개선
  • recentIds 무한 증가 방지를 위해 최근 500건만 유지하도록 제한
  • 상태 저장을 Slack 전송보다 먼저 수행하여 중복 알림 발생 방지
  • RSS 피드 발행 빈도 분석 문서 작성 (3시간 주기 / 50건 fetch 설정의 안정성 검증)
  • slack 패키지 export 구조 변경 (thread 모듈 분리)

Made with Cursor

@wuzoo wuzoo self-assigned this Apr 5, 2026
@wuzoo wuzoo requested a review from jogpfls April 5, 2026 07:19
Copy link
Copy Markdown
Member

@ExceptAnyone ExceptAnyone left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고생하셨습니다! 소소한 의견 남겨봤어용

@@ -1,59 +1,2 @@
import type { ChatPostMessageArguments } from "@slack/web-api";
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 베럴파일의 존재이유가 뭔가요? client.ts도 따로 export하고, thread.ts도 따로 export해주는거 같아서요!
여기서 types랑 redis만 관리하는 이유가 있을까요?

};
}

export const createSlackThread = async (id: string, _message: SlackThreadMessage) => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

보통 언더바는 안쓰는 값이라는 암묵적인 뜻이 있는 걸로 아는데, _message에 언더바가 붙은 이유는 뭔가여?

import type { SlackThreadData } from "./types";

function toChatPostMessageArgs({ channel, message }: SlackThreadMessage) {
if (message.blocks !== undefined) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제가 머리가 나빠서 그런가... message의 blocks 값이 undefined가 아니라는건 어떤 의미인가요?
blocks가 의미하는게 뭔지 주석으로 써놓는건 어떨까싶네요

channel: response.channel ?? _message.channel,
thread_ts: response.ts ?? "",
};
await setSlackThreadData(id, data, _message.ex ? { ex: _message.ex } : undefined);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

redis instance에 저장하고 끝인가요?
그 후에 redis에서 캐싱해오기 위한 로직인지 궁금합니다. just wonder

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아 리뷰하면서 이해완료했슴니다 ㅎㅎ

Comment on lines +19 to +20
expect(item.title).toBeTruthy();
expect(item.link).toBeTruthy();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

item에 id,title,link,pubDate,content가 전부 return되는데, title과 link만 테스트하신 기준이 있으신가요?
나머지 필드도 테스트하거나, 이 두개만 검증하는 이유가 주석에 있음 좋을 것 같아요.


expect(filtered.length).toBeGreaterThan(0);

await sendSlackNotification(filtered);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거 sendSlackNotificaton 모킹 안해도 되나요???

it.skipIf(!hasValidGoogleKey)(
"AI 필터링이 관련 뉴스를 올바르게 분류한다",
async () => {
const filtered = await filterFrontendNews(mockNewsItems);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

음.. 뭔가 제미나이 무료버전이면, 항상 mock-1,3,5를 선택한다는 보장이 없을지도 ㅋㅋㅋㅋ

Comment on lines +12 to +38
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);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(어쩌구);                                           
  };    

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요로케 분리 하니까 흐름이 선언적으로 잘 보이네요 코드가 예뿌다

});

/** 정기 fetch */
cron.schedule(ENV.cronSchedule, () => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 크론의 주체는 무엇인가요? 누군가의 컴퓨터가 켜져있어야만 동작하는건지 궁금합니다!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아 도커 확인했습니다. 제가 잘 몰라서 그러는데, 도커도 클라우드에 띄우는거 아니면, 노트북 덮었을 때 같이 꺼지는거 아닌가여??

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금 어떻게 띄워져잇는지 궁금하네용


const buildNewsBlocks = (items: FilteredNewsItem[]): KnownBlock[] => {
const capped = items.slice(0, MAX_SLACK_ITEMS);
const blocks: KnownBlock[] = [
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아 blocks가 뭔가 슬랙 메세지 형태(?) 같은건가 보네요

Copy link
Copy Markdown
Member

@jogpfls jogpfls left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

너무 어려웠지만.... 코드리뷰하면서 공부가 정말 많이 된 것 같아요 !!!! 무무봇이 아닌 새로운 봇의 등장... 항상 배우고 갑니다!! 👍

Comment on lines +55 to +59
await saveStates(latestTimestamp, allProcessedIds);

await sendSlackNotification(frontendNews);
console.log(`[${new Date().toISOString()}] 작업 완료`);
};
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

만약 saveStates로 저장까지는 성공했는데 sendSlackNotification은 실패하게 되면 redis에는 이미 처리 완료로 기록되고 다음 실행 때 해당 뉴스들이 새 뉴스로 인식되지 않고 건너뛰게 되면서 이 타이밍에 저장했던 뉴스들은 Slack에 전송되지 않은 채 유실될 것 같은데 이 부분은 어쩔 수 없는 부분일까요?!


> 분석일: 2026-04-05
> 분석 대상: https://news.hada.io/rss/news
> 목적: 현재 fetch 설정(3시간 주기 / 50건)으로 뉴스 유실 없이 안정적으로 운영 가능한지 검증
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

실제 slack에 제공하는 주기가 3시간인가요 ??

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

feed 요청이 실패할 경우의 에러 처리는 없어도 괜찮나요 ?!

Comment on lines +12 to +38
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);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요로케 분리 하니까 흐름이 선언적으로 잘 보이네요 코드가 예뿌다

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants