5분 AI.
TrendShift 딥다이브 · 2026-08-11 갱신

Horizon 딥다이브
— 10개 출처를 긁어 프로파일로 채점·정제하는 개인용 뉴스 레이더

Thysrael/Horizon은 Hacker News·RSS·Reddit·텔레그램·X·GitHub·OpenBB·OSS Insight·GDELT·구글뉴스 10종 출처를 한 번에 긁어와, 같은 사건을 합치고, AI로 점수를 매겨 걸러내고, 웹 검색으로 배경지식과 커뮤니티 반응을 붙인 뒤, 영어·중국어 이중언어 일일 브리핑으로 만들어 GitHub Pages·이메일·웹훅·MCP로 배달하는 파이썬 파이프라인이다. 핵심 차별점은 "AI가 다 알아서" 가 아니라, 어떤 출처를 믿을지·어떤 기준으로 점수를 줄지·무엇을 보강할지를 전부 사람이 파일로 적어 두는 프로파일 구조라는 점이다 — 2026년 7월 말 도입된 이 프로파일 시스템이 지금 이 저장소의 뼈대다.
(저장소: Thysrael/Horizon · Python 3.11+ · MIT · uv 패키징 · 출처 10종 · 프로파일 4종 기본 제공 · ⭐약 5.8k · 포크 753 · 기여자 25명 · 최신 커밋 2026-08-10 · 2026-02 시작, 255커밋)
목차
  1. 프로젝트 한 줄 요약
  2. 왜 주목받는가
  3. 기술 스택 전체 지도
  4. 아키텍처 심화 분석 — 수집 전략이 핵심
  5. 디렉토리 구조 해부
  6. 학습 포인트
  7. 하드웨어 / 시스템 요구사항
  8. 직접 해볼 수 있는 실습 과제
  9. 관련 기술 심화 학습 로드맵
  10. 핵심 키워드 사전
  11. 참고 링크

1프로젝트 한 줄 요약

"내가 고른 정보원만 긁어와, 내가 적어 둔 기준으로 AI가 채점·요약해 매일 한 편으로 배달"

Horizon은 한 문장으로 "개인용 뉴스 통신사"다. 흩어진 정보원에서 글을 모으는 수집기(scraper), 같은 이야기를 합치고 중요도를 매기는 정제 파이프라인, 그리고 결과를 웹·메일·채팅으로 내보내는 배달 계층의 세 부분으로 이뤄진다.

슬로건은 "Enjoy the News itself. Leave others to Horizon" — 읽는 즐거움만 남기고 나머지 잡일은 넘기라는 뜻이다. 저자가 README에 쓴 문제의식은 이렇다. "좋은 뉴스는 흩어져 있고, 나쁜 뉴스는 끝이 없다." 그래서 Horizon은 요약기가 아니라 필터를 자처한다. AI는 노이즈를 줄이는 데 쓰고, "무엇이 볼 만한가"라는 취향 판단은 사람이 설정 파일로 남긴다.

한눈에 이해하는 비유

뉴스 앱이 "남이 짜 준 편성표"라면, Horizon은 "내가 고용한 신입 기자"다

포털 뉴스나 추천 피드는 남의 알고리즘이 고른 것을 받아 본다. 무엇을 왜 보여 주는지 알 수 없고, 빼 달라고 할 수도 없다.

Horizon은 반대다. 아침마다 내가 지정한 20곳을 돌며 기사를 긁어오는 신입 기자를 한 명 두는 것에 가깝다. 그 기자에게는 업무 지침서(프로파일)가 있다 — "이런 건 10점, 저런 건 3점", "모르는 회사 이름이 나오면 검색해서 한 문단 붙여라", "댓글에서 반론이 있으면 같이 적어라". 지침서는 마크다운 파일이라 내가 직접 고쳐 쓴다. 기자는 매일 같은 지침으로 일하므로 결과가 흔들리지 않는다.

하루치 실행은 대략 이렇게 흐른다. 아래 숫자는 파이프라인 단계 번호이고, 실제 코드에서도 src/orchestrator.pyrun() 안에 같은 순서로 주석이 달려 있다.

1. 시간창 결정   지금부터 거꾸로 24시간(time_window_hours)
2. 수집         10개 출처를 asyncio로 동시에 긁기
3. URL 병합      같은 주소를 가리키는 항목을 하나로 (트래킹 파라미터 제거 후 비교)
4. AI 분석       항목마다 프로파일 배정 + 점수 + 태그 + 한 줄 요약
5. 선별         점수 임계값 통과 → AI 의미론적 중복 제거 → 카테고리 균형 맞추기
6. 보강         살아남은 것만 웹 검색으로 배경·영향·커뮤니티 반응 덧붙이기
7. 요약·배달     언어별 마크다운 생성 → Pages·메일·웹훅·MCP

비용 구조를 이해하는 열쇠는 4번과 6번 사이의 5번이다. 값싼 채점은 전량에 돌리고, 비싼 보강(웹 검색 + 긴 생성)은 걸러낸 소수에만 돌린다. 뒤에서 자세히 본다.

2왜 주목받는가

"AI 뉴스 요약기는 흔하다 — Horizon은 그 앞뒤의 지저분한 일을 다 처리한다"

"RSS를 읽어 GPT로 요약한다"는 주말 프로젝트는 수백 개가 있다. Horizon이 별 5.8천 개를 모은 이유는 요약이 아니라 요약 전후의 궂은일을 전부 코드로 해결했기 때문이다. 넷으로 나눠 본다.

① API 키 없이 긁을 수 있는 길을 끝까지 찾는다

대부분의 개인 프로젝트는 "Reddit API 키 발급받으세요"에서 막힌다. Horizon은 키 없이 되는 경로를 먼저 시도하고, 막히면 다음 경로로 넘어가는 방식을 출처마다 심어 뒀다. Reddit은 옛 HTML → JSON → RSS 3단, 텔레그램은 도메인 3개를 번갈아 시도하는 식이다(4장에서 상세히 다룬다). GDELT·구글뉴스·Hacker News·OSS Insight는 아예 키가 필요 없는 공개 엔드포인트만 쓴다.

② "프로파일"로 판단 기준을 파일에 고정했다

2026년 7월 31일 커밋 feat(profile): add profile-driven processing으로 들어온 구조다. 그전에는 점수 기준과 요약 지시가 코드 안 프롬프트 문자열에 박혀 있었는데, 지금은 폴더 하나가 곧 하나의 편집 방침이다.

profiles/tech-news/
├── profile.json      어떤 블록을 어떤 도구로 만들지 (구조)
├── match.md          "이 항목이 이 프로파일에 해당하는가?" 판정 지시
├── analysis.md       점수 기준 (무엇이 10점이고 무엇이 3점인가)
└── enrichment.md     배경·영향·댓글 요약을 어떻게 쓸지

기본 제공은 tech-news(기술 뉴스)·tech-blog(기술 블로그)·finance-news(금융)·ai-creator(AI 크리에이터) 4종이고, 사용자가 폴더를 추가하면 그대로 늘어난다. 출처마다 프로파일을 지정할 수도, "auto"로 두어 AI가 항목별로 어느 방침에 해당하는지 고르게 할 수도 있다.

용어
프로파일 (processing profile)
"이런 종류의 글은 이렇게 다뤄라"를 적어 둔 폴더. 판정·채점·보강 지시가 각각 마크다운 파일로 들어 있어, 파이썬을 몰라도 글로 고쳐 쓸 수 있다. 같은 파이프라인에 기술 뉴스와 금융 뉴스를 다른 잣대로 통과시키는 장치.

③ 중복 제거를 두 겹으로 한다

같은 사건이 HN·Reddit·RSS에 동시에 뜨는 건 일상이다. Horizon은 기계적 병합(같은 URL — 단, 트래킹 파라미터를 벗겨 낸 뒤 비교)과 의미론적 병합(제목·태그·요약을 AI에 통째로 보여 주고 "같은 사건인 것끼리 묶어라")을 순서대로 건다. 후자에서는 버리는 쪽의 댓글 내용을 살아남는 쪽에 합쳐 넣어 정보가 사라지지 않게 한다.

④ 결과를 내보내는 길이 넷이다

브리핑 하나를 만들어 GitHub Pages 사이트(Jekyll), 자체 SMTP 뉴스레터, 웹훅(Feishu·DingTalk·Slack·Discord·범용), 그리고 MCP 서버로 동시에 내보낸다. 특히 MCP는 Claude 같은 AI 클라이언트가 브리핑을 직접 조회하게 해 준다 — 뉴스 파이프라인 자체를 AI의 도구로 노출하는 셈이다.

비교로 이해하기

흔한 "RSS + GPT 요약" 스크립트는 출처가 RSS 하나, 중복 제거 없음, 점수 대신 최신순, 결과는 콘솔 출력 혹은 파일 하나다. 잘 돌아가도 구독 개수가 늘면 바로 무너진다.

Horizon은 같은 자리에서 출처 10종, 중복 제거 2단, 프로파일별 점수 임계값, 카테고리별 상한, 4개 배달 경로를 갖는다. 개인 스크립트와 사내 도구 사이 어딘가에 있는 완성도다.

3기술 스택 전체 지도

"파이썬 비동기 + 멀티 LLM 추상화 + 마크다운 기반 설정, 프런트엔드는 사실상 없다"

Horizon은 웹 서버가 아니라 배치 파이프라인이다. 하루 한 번(또는 크론 주기마다) 실행되고 끝나는 프로그램이라, 흔한 웹 스택 대신 "긁고 → 처리하고 → 파일로 뱉는" 도구들로 채워져 있다.

런타임 · 패키징

항목선택이유 / 메모
언어Python 3.11+requires-python = ">=3.11". 예외 그룹·성능 개선 세대
패키지 관리uv (Astral)uv sync 한 줄. pip -e .도 지원
빌드 백엔드hatchlingprofiles/src/_builtin_profiles휠에 동봉
진입점4개horizon(본체) · horizon-mcp · horizon-wizard · horizon-webhook
라이선스MIT

수집 · 파싱 계층

라이브러리쓰이는 곳
httpx모든 HTTP. AsyncClient 하나를 전 수집기가 공유(연결 재사용)
feedparserRSS/Atom 파싱 — RSS·구글뉴스·Reddit RSS 폴백
beautifulsoup4HTML 긁기 — 옛 Reddit, 텔레그램 웹 프리뷰
trafilatura기사 원문 본문 추출(요약문만으론 부족할 때). 7월 31일 필수 의존성으로 승격
playwright extraX(트위터) 브라우저 모드 — 쿠키로 로그인 세션 재사용
openbb extra금융 뉴스·공시. 동기 SDK라 asyncio.to_thread로 감싼다

AI · 처리 계층

라이브러리쓰이는 곳
anthropic · openai · google-genaiSDK 3개로 제공사 9종(Anthropic·OpenAI·Azure·Gemini·DeepSeek·Qwen·Doubao·MiniMax·Ollama)을 지원. 대부분이 OpenAI 호환이라 OpenAIClient 하나를 재사용한다
pydantic v2설정·ContentItem 전 구간 스키마. extra="forbid"로 오타 난 설정 키를 즉시 실패시킨다
tenacityAPI 호출 재시도·백오프
ddgs보강 단계의 웹 검색(DuckDuckGo)
opencc중국어 간·번체 변환
mcpMCP 서버 구현 — 브리핑을 AI 클라이언트의 도구로 노출

출력 · 운영

항목내용
콘솔rich — 진행 상황을 stderr 단일 Console로 통일(7월 31일 리팩터링). stdout은 파이프용으로 비워 둔다
사이트생성한 마크다운에 Jekyll 프런트매터를 붙여 docs/_posts/에 저장 → GitHub Pages
메일nodemailer 아닌 파이썬 SMTP. IMAP으로 구독/해지 메일을 읽어 자동 처리
컨테이너Docker + compose. --build-arg EXTRAS=trafilatura,openbb로 선택 의존성 주입
CIGitHub Actions — deploy-docs.ymldocs/ 변경 시 Pages 배포. 일일 실행 워크플로는 현재 비활성(아래 주의 참조)
테스트pytest, 테스트 파일 39개. 출처별 파서·중복제거·CLI·이메일까지 커버
용어
extras (선택 의존성)
"기본 설치엔 넣지 않고, 원하는 사람만 추가로 까는 부품". Horizon은 openbb(금융)·twitter(브라우저 자동화)·dev(테스트)를 이렇게 뺐다. 무거운 패키지를 모두에게 강요하지 않으려는 배려이자, 설치 실패 지점을 줄이는 실무 기법.

4아키텍처 심화 분석 — 수집 전략이 핵심

"이 저장소에서 가장 배울 게 많은 부분은 AI가 아니라, 막히지 않고 긁어 오는 방법이다"

AI 호출은 사실 어렵지 않다. 실제로 매일 돌리는 파이프라인을 무너뜨리는 건 수집 단계다 — Reddit이 차단하고, 텔레그램 도메인이 막히고, 한 출처가 죽으면 전체가 멈춘다. Horizon의 src/scrapers/는 이 문제들에 대한 구체적인 답의 모음이다. 이 장은 거기에 지면을 크게 쓴다.

4-1. 전체 구조도

┌──────────────────────────────────────────────────────────────┐ │ 설정 data/config.json + profiles/*/ (판단 기준 = 마크다운) │ └───────────────┬──────────────────────────────────────────────┘ │ ▼ since = now - time_window_hours (기본 24h) ┌──────────────────────────────────────────────────────────────┐ │ [1] 수집 httpx.AsyncClient 1개 공유 · asyncio.gather 전체 동시 │ │ │ │ HN RSS Reddit 텔레그램 X GitHub OpenBB OSS GDELT 구글뉴스 │ │ │ │ │ │ │ │ │ │ │ │ │ │ └────┴─────┴───────┴───────┴──────┴───────┴──────┴─────┴──────┘ │ │ ↓ 전부 ContentItem 한 종류로 정규화 │ │ 실패한 출처는 SourceFetchOutcome(status=failure)로 격리 — 계속 진행 │ └───────────────┬──────────────────────────────────────────────┘ ▼ ┌──────────────────────────────────────────────────────────────┐ │ [2] URL 병합 (기계적·무료) │ │ utm_* · fbclid · gclid 등 트래킹 파라미터 제거 후 주소 비교 │ │ 같은 주소면 내용이 가장 풍부한 것만 남기고 메타데이터 합침 │ └───────────────┬──────────────────────────────────────────────┘ ▼ ┌──────────────────────────────────────────────────────────────┐ │ [3] AI 분석 (전량 · 값싼 호출) │ │ 프로파일 배정 → 점수 · 태그 · 한 줄 요약 │ └───────────────┬──────────────────────────────────────────────┘ ▼ ┌──────────────────────────────────────────────────────────────┐ │ [4] 선별 ① 프로파일별 점수 임계값 │ │ ② AI 의미론적 중복 제거 (같은 사건 묶기, 댓글은 합침) │ │ ③ 균형 다이제스트 (카테고리별 상한) │ └───────────────┬──────────────────────────────────────────────┘ ▼ ← 여기서 개수가 확 줄어든 뒤에야 ┌──────────────────────────────────────────────────────────────┐ │ [5] 보강 (소수 · 비싼 호출) 웹 검색 + 배경 · 영향 · 커뮤니티 반응 │ └───────────────┬──────────────────────────────────────────────┘ ▼ ┌──────────────────────────────────────────────────────────────┐ │ [6] 요약 · 배달 언어별 마크다운 → Pages · 메일 · 웹훅 · MCP │ └──────────────────────────────────────────────────────────────┘

구조에서 가장 중요한 결정은 [4]가 [5]보다 앞에 있다는 것이다. 채점은 전량에 하되 보강은 살아남은 소수에만 한다. 웹 검색이 붙는 보강 호출이 채점보다 훨씬 비싸기 때문에, 이 순서 하나가 토큰 비용을 몇 배 단위로 가른다.

4-2. 수집기의 계약 — 놀랄 만큼 얇다

10종이나 되는 출처를 붙였는데도 공통 인터페이스는 src/scrapers/base.py 47줄이 전부다.

class BaseScraper(ABC):
    def __init__(self, config: dict, http_client: httpx.AsyncClient):
        self.config = config
        self.client = http_client        # ← 클라이언트를 '주입받는다'

    @abstractmethod
    async def fetch(self, since: datetime) -> List[ContentItem]:
        ...

    def _generate_id(self, source_type, subtype, native_id) -> str:
        return f"{source_type}:{subtype}:{native_id}"   # 예) reddit:subreddit:1a2b3c

계약은 셋뿐이다. HTTP 클라이언트를 스스로 만들지 않고 주입받는다. fetch(since) 하나만 구현하면 된다. 무엇을 긁든 ContentItem이라는 하나의 자료형으로 돌려준다.

왜 이게 중요한가

클라이언트를 주입받는다는 건, 10개 수집기가 커넥션 풀 하나를 나눠 쓴다는 뜻이다. 각자 클라이언트를 만들면 매번 새 TCP·TLS 연결을 열게 되고, 출처가 늘수록 느려진다. 타임아웃 정책도 한 곳(timeout=30.0)에서 통제된다.

그리고 ContentItem으로 통일한 덕분에 뒤쪽 파이프라인 전체가 출처를 몰라도 된다. 중복 제거·채점·요약 코드는 "이게 트윗인지 RSS인지"를 신경 쓰지 않는다. 그래서 새 출처를 붙일 때 고칠 곳이 수집기 파일 하나로 끝난다.

4-3. 출처별 수집 방식 한눈에

Horizon이 지원하는 10종을 무엇으로 긁는가 기준으로 정리하면 이렇다. 인증이 필요한 건 X와 (선택적) GitHub뿐이라는 점이 눈에 띈다.

출처접근 방법기본 한도
Hacker News공식 Firebase API — topstories → 항목별 상세 → 상위 댓글까지 동시 조회불필요상위 30개 · 100점 이상
RSSfeedparser. URL 안 ${TOKEN}을 환경변수로 치환(유료 피드 대응)불필요피드별
Reddit3단 폴백 — old.reddit HTML → JSON → RSS불필요25개 · 10점 이상 · 댓글 5
텔레그램공개 채널 웹 프리뷰(/s/채널명) HTML 파싱 · 도메인 3개 로테이션불필요최근 20개
X (트위터)2모드 — Apify 액터(유료 대행) 또는 Playwright 브라우저(쿠키)필요사용자별
GitHub공식 API — 사용자 공개 활동 / 저장소 릴리스선택(한도↑)
OpenBBOpenBB SDK news.company() — 워치리스트 티커별 금융 뉴스제공사별워치리스트당 1콜
OSS Insight공개 API — 기간·언어별 급상승 저장소(별 증가량)불필요
GDELTGDELT 2.0 DOC API — 전 세계 뉴스, 언어·국가 필터를 질의 연산자불필요75건(상한 250)
구글뉴스공개 RSS 검색 — 시간창을 when:24h 연산자로 표현불필요질의별

4-4. 막히면 다음 길로 — 폴백 사다리 세 가지

Horizon 수집 코드에서 가장 실전적인 부분이다. 세 사례가 각기 다른 종류의 장애에 대응한다.

사례 1 — 차단 대응

Reddit: 옛 HTML → JSON → RSS 3단 사다리

Reddit은 2023년 이후 비인증 접근을 꾸준히 조여 왔다. Horizon은 순서를 뒤집어 대응한다. 가장 안 막히는 old.reddit.com HTML을 먼저 긁고(BeautifulSoup으로 div.thing.link 파싱), 결과가 비면 공식 JSON으로, JSON이 차단(RedditBlockedError)되면 마지막으로 RSS로 내려간다.

단계마다 얻는 정보가 다르다는 점이 재밌다. HTML에서는 점수·댓글 수·플레어까지 뽑히지만, RSS 폴백에서는 score: None으로 두고 "fallback": "rss" 표시를 메타데이터에 남긴다 — "이건 열등한 경로로 얻은 데이터"라고 정직하게 기록하는 것이다.

사례 2 — 망 차단 우회

텔레그램: 같은 내용을 주는 도메인 3개를 돌려 쓴다

텔레그램은 봇 API 대신 공개 채널 웹 프리뷰를 긁는다. API 키가 필요 없는 대신 도메인이 막히기 쉬운데, Horizon은 telegram.me/stelegram.dog/st.me/s를 차례로 시도한다(7월 14일 커밋에서 대체 도메인 추가). 429(요청 과다)를 받으면 Retry-After 헤더 값만큼 기다렸다가 한 번 더 시도한다.

본문 처리도 세심하다. <br>을 줄바꿈으로 바꾼 뒤 태그를 벗기고, 메시지 안의 첫 외부 링크를 대표 URL로 삼는다(없으면 메시지 주소). 그래야 같은 기사를 공유한 텔레그램 글과 RSS 항목이 뒤에서 같은 URL로 병합된다.

사례 3 — 유료/무료 선택

X(트위터): 대행 서비스 모드와 브라우저 모드 두 갈래

mode 설정 하나로 갈린다. Apify 모드는 외부 액터에 작업을 던지고 3초 간격으로 최대 180초까지 완료를 폴링한 뒤 결과 데이터셋을 받아 온다(돈이 들지만 안정적). Playwright 모드는 브라우저를 직접 띄우고 내보낸 쿠키 파일로 로그인 상태를 재현한다 — playwright-stealth로 자동화 탐지를 피하고, 쿠키 파일이 여러 개면 계정을 나눠 쓴다.

다만 Dockerfile은 Playwright 브라우저와 시스템 패키지를 설치하지 않는다고 README가 명시한다. 컨테이너로 X를 긁으려면 이미지를 직접 손봐야 한다.

4-5. 한 출처가 죽어도 브리핑은 나온다

매일 도는 파이프라인에서 제일 흔한 사고는 "출처 하나가 죽어서 전체가 실패"다. Horizon은 수집을 _fetch_with_progress()로 감싸 예외를 그 자리에서 잡고 결과 객체로 바꾼다.

try:
    items = await scraper.fetch(since)
except Exception as exc:
    return SourceFetchOutcome(source_name=name, status="failure",
                              error=f"{type(exc).__name__}: {exc}")
return SourceFetchOutcome(source_name=name,
                          status="success" if items else "empty", items=items)

그래서 asyncio.gather(*tasks)절대 중간에 터지지 않는다. 살아남은 출처의 항목만 모아 계속 진행하고, 실패는 보고서에 남는다. 다만 전부 실패한 경우에만 all_failed로 전체를 중단시킨다 — 빈 브리핑을 발행해 구독자에게 보내는 사고를 막는 안전장치다.

배울 점
"부분 실패는 데이터, 전면 실패는 예외"

실패를 예외로 던지느냐 값으로 돌려주느냐를 범위에 따라 다르게 정했다. 개별 출처 실패는 (status=failure)이라 파이프라인이 계속 흐르고, 전 출처 실패는 예외라 발행을 멈춘다. 매일 자동 발행되는 시스템이라면 반드시 흉내 낼 가치가 있는 구분이다.

진행 상황 출력도 세심하다. 출처 안에 하위 출처가 여럿이면(예: 여러 서브레딧) _sub_source_label()r/LocalLLaMA·@채널명·피드 이름 순으로 라벨을 찾아 어디서 몇 건이 왔는지 분해해 보여 준다. "오늘 왜 이 출처만 0건이지?"를 로그만 보고 알 수 있다.

4-6. 중복 제거 — 무료 단계를 먼저, 유료 단계를 나중에

1단계는 URL 정규화 비교다. 핵심은 비교 전에 주소를 씻는 것.

_TRACKING_QUERY_PARAMETERS = {"_ga","dclid","fbclid","gclid","igshid",
                              "li_fat_id","mc_cid","mc_eid","msclkid",
                              "ttclid","twclid","vero_id"}
# + utm_ 으로 시작하는 모든 파라미터 제거
# + 기본 포트(80/443) 제거, 끝 슬래시 정리, 호스트 소문자화

같은 기사라도 트위터에서 온 링크엔 ?twclid=…, 뉴스레터에서 온 링크엔 ?utm_source=…가 붙는다. 이걸 벗기지 않으면 같은 기사가 서로 다른 주소로 보여 중복 제거가 통째로 무력화된다. 씻은 뒤 같은 주소로 묶이면 내용이 가장 풍부한 것만 남기고 메타데이터를 합친다.

2단계는 AI 의미론적 중복 제거다. 주소가 달라도 같은 사건인 경우 — 같은 발표를 다룬 TechCrunch 기사와 The Verge 기사 — 를 잡는다. 항목의 제목·태그·요약을 번호 매겨 한 번의 호출로 보내고, {"duplicates": [[0, 3, 7], ...]} 형태로 묶음을 받는다(항목마다 호출하는 게 아니다). 프롬프트는 "같은 제품이라도 다른 사건이면 중복이 아니다"(제미나이 출시 vs 제미나이 탈옥)라고 못 박고, 애매하면 남기는 쪽으로 기울이라고 지시한다.

[0] OpenAI announces GPT-5.5
    Tags: AI, LLM      Summary: ...
[3] GPT-5.5 발표, 컨텍스트 200만 토큰
    Tags: AI, OpenAI   Summary: ...
   ↓ AI 응답: {"duplicates": [[0, 3]]}
   ↓ 0을 남기고 3을 버리되, 3의 댓글 내용은 0에 이어붙임
0.content += "\n\n--- From reddit ---\n(레딧 댓글들)"

설계상 세 가지가 영리하다. 첫째, 점수 내림차순으로 정렬한 뒤 호출하므로 각 묶음의 첫 번째가 항상 최고점 항목이다(무엇을 남길지 AI가 고민할 필요가 없다). 둘째, 버리는 항목의 댓글을 살아남는 항목에 합쳐 커뮤니티 반응이 유실되지 않는다. 셋째, 이 비교는 같은 프로파일 안에서만 이뤄진다 — 기술 뉴스와 금융 뉴스는 애초에 서로 견주지 않는다.

그리고 AI 호출이 실패하면 그냥 원본을 반환하고 넘어간다. 중복이 조금 남는 브리핑이 브리핑이 아예 없는 것보다 낫다는 판단이다 — 4-5절의 "부분 실패는 값으로" 원칙이 여기서도 반복된다.

4-7. SSRF 방어 — 남이 준 URL을 그대로 열지 않는다

설정에서 온 웹훅 주소나 피드가 알려 준 기사 링크는 개발자가 고정해 둔 주소가 아니다. 이를 그대로 요청하면 공격자가 http://169.254.169.254/(클라우드 메타데이터) 같은 내부 주소를 넣어 서버 내부를 훔쳐보게 만들 수 있다. 7월 13일 feat(ALL): enhance security 커밋으로 src/url_security.py가 들어왔다.

[1단계] 구조 검사 (네트워크 없음)
  스킴       http/https만 허용
  자격증명    user:pass@host 형태 거부
  포트       1~65535 범위 밖 거부
  호스트      localhost · *.localhost 거부 (끝점 제거 후, 대소문자 무시)

[2단계] DNS 해석 후 IP 검사
  해석된 주소가 하나라도 사설·루프백·링크로컬·멀티캐스트·예약 대역이면 거부
  → 여러 A 레코드 중 하나만 내부 IP인 우회를 막는다

진짜 핵심은 리다이렉트 처리다. httpx의 자동 추적(follow_redirects=True)을 끄고 한 홉씩 직접 따라가며 매번 다시 검증한다. 안 그러면 겉보기엔 멀쩡한 공개 주소가 302로 내부 주소를 가리키는 순간 그대로 뚫린다.

적용 범위는 정확히 두 곳이다 — 웹훅 전송기사 원문 추출(trafilatura). 둘 다 "목적지를 사용자나 외부 데이터가 정한다"는 공통점이 있다. 반대로 Hacker News·GitHub API처럼 주소가 코드에 박혀 있는 호출은 이 관문을 타지 않는다 — 검증을 무조건 다 거는 게 아니라 신뢰 경계가 있는 곳에만 건 설계다.

용어
SSRF (서버 측 요청 위조)
공격자가 서버로 하여금 공격자가 지정한 주소로 요청을 보내게 만드는 취약점. 서버는 방화벽 안에 있으므로, 바깥에서는 못 닿는 내부 API·클라우드 메타데이터에 접근하게 된다. 이름만 검사하면 evil.com이 사설 IP로 해석되는 우회를 놓치므로, DNS 해석 결과까지 확인하는 것이 정석이다.

5디렉토리 구조 해부

"파이프라인 단계가 곧 폴더 이름 — 코드를 처음 열어도 길을 잃지 않는다"

폴더 구조가 4장의 파이프라인 그림과 거의 1:1로 대응한다. 긁는 것은 scrapers/, 판단하는 것은 ai/, 내보내는 것은 services/다.

Horizon/ ├── src/ │ ├── main.py 진입점 (horizon 명령) │ ├── _cli.py 공통 CLI 플래그 (-d/--data-dir, -c/--config, -l/--log-level) │ ├── orchestrator.py ★ 파이프라인 전 단계를 순서대로 부르는 지휘자 (1,089줄) │ ├── models.py ★ pydantic 스키마 — 설정 + ContentItem (615줄) │ ├── url_security.py SSRF 방어 (외부 URL 검증) │ │ │ ├── scrapers/ ★ [수집] 출처 10종 — 여기가 이 저장소의 노른자 │ │ ├── base.py 공통 계약 47줄 │ │ ├── hackernews.py Firebase API + 상위 댓글 │ │ ├── rss.py feedparser + 본문 추출기 연동 │ │ ├── reddit.py 3단 폴백 (563줄 — 가장 큼) │ │ ├── telegram.py 웹 프리뷰 파싱 + 도메인 로테이션 │ │ ├── twitter.py Apify 액터 모드 │ │ ├── twitter_playwright.py 브라우저 + 쿠키 모드 │ │ ├── github.py 사용자 활동 · 릴리스 │ │ ├── openbb.py 금융 (동기 SDK를 to_thread로 감쌈) │ │ ├── ossinsight.py 급상승 저장소 │ │ ├── gdelt.py 전 세계 뉴스 (키 불필요) │ │ └── google_news.py 구글뉴스 RSS 검색 │ │ │ ├── extractors/ 기사 원문 추출기 (플러그인 방식 · trafilatura 구현) │ │ ├── base.py registry.py trafilatura.py │ │ │ ├── ai/ ★ [판단] LLM 호출 계층 │ │ ├── client.py 제공사 추상화 (OpenAI/Anthropic/Gemini/호환 엔드포인트) │ │ ├── classifier.py 항목 → 프로파일 배정 │ │ ├── analyzer.py 점수 · 태그 · 한 줄 요약 │ │ ├── enricher.py 배경 · 영향 · 커뮤니티 반응 (웹 검색 사용) │ │ ├── summarizer.py 최종 브리핑 마크다운 조립 │ │ ├── localization.py 언어별 출력 · 간체/번체 변환 │ │ ├── tokens.py 토큰 사용량 집계 │ │ └── prompting/ 프롬프트 모음 (분류·중복제거·분석·보강) │ │ │ ├── processing/ 프로파일 로딩 · 도구 화이트리스트 · 본문 전처리 │ ├── storage/manager.py data/ 저장 + safe_output_path (경로 탈출 방어) │ ├── services/ [배달] email.py · webhook.py · webhook_cli.py │ ├── mcp/ MCP 서버 (server · service · adapter · run_store) │ └── setup/ 대화형 마법사 (관심사 → 설정 자동 생성) │ ├── profiles/ ★ [기준] 판단 방침 — 코드가 아니라 글로 적힌 부분 │ ├── tech-news/ { profile.json · match.md · analysis.md · enrichment.md } │ ├── tech-blog/ finance-news/ ai-creator/ │ ├── data/ config.json (내 설정) · config.example.json · 산출물 ├── docs/ 설정·프로파일·점수·수집기 가이드 + GitHub Pages 사이트 ├── tests/ pytest 39개 파일 └── docker-compose.yml · Dockerfile · .github/workflows/

처음 읽는 사람에게 권하는 순서는 이렇다. orchestrator.pyrun() 하나만 읽으면 전체 흐름이 잡힌다(주석에 1~7단계가 번호로 달려 있다). scrapers/base.py 47줄로 수집기 계약을 본다. 관심 있는 출처 하나(telegram.py가 짧고 재밌다)를 읽는다. profiles/tech-news/analysis.md를 열어 "점수 기준이 코드가 아니라 글"이라는 걸 확인한다.

주의
가장 큰 파일이 가장 중요한 파일은 아니다

reddit.py가 563줄로 수집기 중 제일 크지만, 그 길이의 대부분은 3단 폴백 각각의 파서다. 아키텍처를 이해하려고 여기부터 열면 세부에 빠진다. 구조는 orchestrator.pybase.py 순으로 잡고, reddit.py는 "차단을 어떻게 우회했나"가 궁금할 때 읽는 게 맞다.

6학습 포인트

"내 자동화에 그대로 옮겨 심을 수 있는 패턴 일곱 가지"

이 저장소의 가치는 "뉴스 앱"이 아니라 매일 돌아가는 수집 자동화를 어떻게 안 죽게 만드는가에 대한 사례집이라는 데 있다. 아래 일곱 개는 뉴스와 무관한 자동화에도 그대로 적용된다.

① 비동기 동시 수집 + 클라이언트 공유

출처 10곳을 순서대로 긁으면 대기 시간이 그대로 더해진다. 각 요청은 대부분 "응답을 기다리는" 시간이라(I/O 바운드), asyncio.gather로 동시에 던지면 전체 시간이 가장 느린 한 곳에 수렴한다. 핵심은 httpx.AsyncClient하나만 만들어 전부에 주입하는 것 — 커넥션 재사용과 타임아웃 일원화를 공짜로 얻는다.

용어
I/O 바운드 (I/O-bound)
일의 대부분이 계산이 아니라 기다림인 작업. 네트워크 요청·파일 읽기가 여기 속한다. CPU는 놀고 있으므로, 기다리는 동안 다른 요청을 시작하는 동시성이 큰 효과를 낸다. 반대로 영상 인코딩 같은 CPU 바운드 작업은 asyncio로 빨라지지 않는다.

② 폴백 사다리 — "안 되면 다음 방법"을 코드로

웹에서 데이터를 긁는 일은 언젠가 반드시 막힌다. Reddit 3단·텔레그램 도메인 3개가 보여 주는 원칙은 두 가지다. 덜 막히는 경로를 먼저 시도하고(공식 API가 항상 1순위는 아니다), 열등한 경로로 얻었음을 데이터에 기록한다("fallback": "rss"). 후자가 없으면 나중에 "왜 이 항목만 점수가 없지?"에서 헤맨다.

③ 부분 실패는 값으로, 전면 실패는 예외로

4-5절에서 본 SourceFetchOutcome 패턴. 자동 발행 시스템에서는 "조금 부족한 결과"와 "아무것도 없는 결과"를 다르게 다뤄야 한다. 전자는 내보내도 되고 후자는 멈춰야 한다. 이 구분을 코드로 명시하지 않으면, 어느 날 조용히 빈 메일이 나간다.

④ 비교하기 전에 정규화하라

URL 중복 제거의 교훈은 일반적이다. 같은 것을 같다고 판정하려면 먼저 표준형으로 씻어야 한다. Horizon은 트래킹 파라미터·기본 포트·끝 슬래시·대소문자를 정리한 뒤 비교한다. 이걸 건너뛰면 중복 제거 로직이 있어도 아무것도 안 잡힌다 — 버그가 아니라 조용한 무력화라 발견도 늦다.

⑤ 값싼 단계로 걸러낸 뒤 비싼 단계를 태워라

LLM 파이프라인 비용의 8할은 순서 문제다. Horizon은 채점(짧은 호출)을 전량에, 보강(웹 검색 + 긴 생성)을 통과한 소수에만 돌린다. 100건을 긁어 10건을 발행한다면, 보강을 앞에 두는 순간 비용이 10배다.

일반화
깔때기 순서 = 비용 설계

어떤 AI 파이프라인이든 단계를 단가 순으로 정렬하고, 개수를 줄이는 필터를 비싼 단계 앞에 배치하면 된다. Horizon에서 이 위치를 담당하는 게 프로파일별 threshold 값이다 — 숫자 하나로 그날의 API 청구서가 결정된다.

⑥ 판단 기준을 코드 밖으로 — 프롬프트를 파일로

프로파일 시스템의 본질은 "설정 파일 늘리기"가 아니라 변경 빈도가 다른 것을 분리한 것이다. 파이프라인 로직은 몇 달에 한 번 바뀌지만, "무엇이 중요한가"라는 기준은 매주 바뀐다. 후자를 마크다운으로 빼 두면 파이썬을 건드리지 않고, 코드 리뷰 없이, 심지어 개발자가 아닌 사람도 고칠 수 있다.

⑦ 외부에서 온 URL은 열기 전에 검증하라

RSS 주소·검색 결과처럼 내가 쓰지 않은 주소로 요청을 보내는 코드가 있다면 SSRF 검증은 선택이 아니다. 이름 검사만으로는 부족하고 DNS 해석 결과가 사설 IP인지까지 봐야 한다는 게 url_security.py의 요지다.

실습 아이디어 (읽고 끝내지 않으려면)

해 볼 것배우는 것
새 출처 하나 붙이기BaseScraper를 상속해 fetch() 하나만 구현. 계약이 얇을수록 확장이 쉽다는 걸 몸으로 안다
내 관심사로 프로파일 만들기analysis.md의 점수 기준을 바꿔 가며 결과가 어떻게 달라지는지 관찰 — 프롬프트가 곧 정책
임계값을 3.0과 8.0으로 바꿔 비교필터 위치가 토큰 비용에 미치는 영향을 직접 측정
출처 하나를 일부러 죽여 보기잘못된 URL을 넣고 나머지 브리핑이 정상 생성되는지 확인 — 격리 설계 체험

7하드웨어 / 시스템 요구사항

"GPU 필요 없음 — 진짜 비용은 전기가 아니라 API 토큰"

Horizon은 모델을 직접 돌리지 않고 외부 API를 호출한다(Ollama를 붙이면 로컬 모델도 가능). 그래서 머신 사양은 거의 문제가 되지 않는다.

항목요구
파이썬3.11 이상
CPU / RAM일반적인 개인 PC나 가장 작은 VPS로 충분. 네트워크 대기가 대부분
GPU불필요 (로컬 모델을 Ollama로 돌릴 때만 별도 고려)
디스크산출물이 마크다운/JSON이라 미미
상시 구동불필요 — 하루 한 번 실행되는 배치. 크론이나 GitHub Actions면 충분
Playwright 모드브라우저 바이너리 + 시스템 라이브러리 필요. 기본 Dockerfile엔 미포함
진짜 비용
API 토큰 — 출처 수 × 항목 수 × 단계 수

수집한 항목 전부가 채점 호출을 타고, 통과한 것이 보강 호출(웹 검색 포함)을 탄다. 출처를 늘리고 임계값을 낮추면 청구서가 곱셈으로 늘어난다.

통제 손잡이는 셋이다. 수집 한도(출처별 fetch_limit·min_score) → 점수 임계값(profile_settings.threshold) → 균형 다이제스트(카테고리별 상한). 처음 돌릴 때는 출처를 2~3개로 좁히고 임계값을 높게 잡아 하루치 실제 사용량을 확인한 뒤 늘리는 게 안전하다. 실행이 끝나면 ai/tokens.py가 집계한 제공사별 입력·출력 토큰이 콘솔 마지막 줄에 찍힌다.

네 번째 손잡이는 언어 개수다. 이중언어 브리핑은 번역이 아니라 언어마다 처음부터 다시 쓰는 방식이다 — 웹 검색 결과는 재사용하지만 보강 글쓰기 호출은 언어 수만큼 통째로 반복된다. 영어만 켜다가 중국어를 추가하면 보강 비용이 대략 두 배가 된다. (반대로 최종 브리핑 조립은 AI를 전혀 쓰지 않는다 — 이미 만들어 둔 조각을 파이썬이 마크다운으로 엮을 뿐이라 공짜다.)

운영 시 주의
저장소에 들어 있는 "매일 자동 실행" 워크플로는 지금 꺼져 있다

일일 파이프라인을 돌리던 .github/workflows/daily-summary.yml은 2026-07-31 커밋에서 파일명이 .yml.disabled로 바뀌었다. GitHub Actions는 .yml/.yaml만 인식하므로 이 파일은 러너에게 보이지 않는다(내용은 그대로 보존 — 되살리려면 이름만 되돌리면 된다).

문제는 README 3개 언어판과 설정 문서가 여전히 이 워크플로를 "바로 쓸 수 있다"고 안내한다는 점이다. 현재 활성 워크플로는 docs/가 바뀔 때 Pages로 배포하는 deploy-docs.yml뿐이고, 스케줄로 도는 것은 없다. 문서만 믿고 "매일 알아서 돌겠지" 하면 브리핑이 하루도 생성되지 않는다 — 직접 크론을 걸거나 워크플로를 되살려야 한다.

8직접 해볼 수 있는 실습 과제

"읽기만 하면 안 남는다 — 난이도별 다섯 개"
난이도 ★☆☆

1. 출처 3개로 하루치 브리핑 뽑아 보기1~2시간

uv syncuv run horizon-wizard로 관심사를 입력해 설정을 만들고, 출처를 Hacker News + RSS 2개로 줄여 uv run horizon --hours 24를 돌린다. 콘솔에 단계별 개수(수집 → 병합 → 통과 → 보강)가 찍히는 걸 눈으로 확인하는 게 목표다.

확인할 것 — 각 단계에서 개수가 얼마나 줄어드는가? 어느 단계가 가장 크게 거르는가?

난이도 ★☆☆

2. 임계값을 바꿔 가며 비용·품질 곡선 그리기1시간

processing.profile_settings.tech-news.threshold3.0 / 6.0 / 8.0으로 바꿔 세 번 돌리고, 그때마다 통과 개수와 ai/tokens.py가 보고하는 토큰 사용량을 기록한다.

배우는 것 — 필터 하나가 API 청구서를 어떻게 좌우하는지 숫자로 체감한다. 6장 ⑤의 "깔때기 순서 = 비용 설계"를 직접 재현하는 실습이다.

난이도 ★★☆

3. 내 관심사 프로파일 직접 쓰기반나절

profiles/tech-news/를 통째로 복사해 profiles/my-topic/을 만들고, analysis.md의 점수 기준을 내 기준으로 고쳐 쓴다("벤치마크 수치가 있으면 가점", "채용 공고는 0점" 같은 식). match.md에는 어떤 글이 이 프로파일에 해당하는지 적는다.

확인할 것 — 같은 입력에 프로파일만 바꿔 돌렸을 때 상위 항목이 어떻게 달라지는가? 프롬프트 한 문단이 곧 편집 정책이라는 걸 체감하게 된다.

난이도 ★★☆

4. 새 출처 수집기 붙이기하루

BaseScraper를 상속해 fetch(since) 하나만 구현한다. 추천 대상은 Lobste.rs(HN과 비슷하지만 JSON이 단순) 또는 Product Hunt RSS. 모델에 새 SourceType을 더하고 orchestrator.fetch_all_sources()에 조건 분기 한 덩이를 추가하면 끝이다.

핵심 포인트 — 반환값을 ContentItem으로 맞추기만 하면 중복 제거·채점·요약 코드는 한 줄도 고칠 필요가 없다. 인터페이스 설계의 효용을 직접 확인하는 과제.

난이도 ★★★

5. 폴백 사다리를 내 수집기에 이식하기이틀+

4번에서 만든 수집기에 2단 이상의 폴백을 넣는다. 1차 경로가 429나 403을 받으면 대체 경로(RSS·미러 도메인·캐시)로 내려가고, 어느 경로로 얻었는지 metadata["fallback"]에 기록한다. 나아가 실패를 예외가 아니라 결과 객체로 돌려주도록 바꿔 본다.

어려운 지점 — "무엇을 실패로 볼 것인가"의 정의다. 0건 반환은 실패인가 정상인가? Horizon은 status="empty"라는 제3의 상태를 둬서 이 구분을 명시했다. 직접 부딪혀 보면 왜 필요한지 알게 된다.

9관련 기술 심화 학습 로드맵

"수집 자동화를 스스로 만들 수 있게 되는 6주 경로"
주차주제구체적으로 할 것
1주차파이썬 비동기async/await·asyncio.gather·세마포어. "왜 I/O 바운드에서만 효과가 있나"를 직접 측정해 확인. httpx 공식 문서의 AsyncClient 절
2주차웹 수집 실무RSS/Atom 구조, feedparser, BeautifulSoup 선택자. robots.txt·이용약관·요청 간격 등 지켜야 할 선을 함께 공부(막히는 이유의 절반은 예의 문제다)
3주차데이터 모델링pydantic v2로 이질적 입력을 하나의 스키마로 정규화. extra="forbid"·검증자·model_copy. Horizon의 models.py를 교재로
4주차LLM 파이프라인구조화 출력(JSON) 받아내기, 파싱 실패 시 폴백, 재시도·백오프(tenacity), 토큰 회계. 프롬프트를 코드에서 분리하는 습관 들이기
5주차안정성 · 보안부분 실패 격리 패턴, 원자적 파일 쓰기(임시 파일 + os.replace), 경로 탈출 방어, SSRF와 리다이렉트 재검증
6주차배포 · 운영Docker 이미지 슬림화, 비루트 실행, 크론/Actions 스케줄링, 시크릿 관리(env·리포 시크릿), 실패 알림

6주를 다 채우기 어렵다면 1·3·5주차만 해도 남는 게 많다. 비동기로 동시에 긁고, 하나의 스키마로 정규화하고, 부분 실패를 격리하는 것 — 이 셋이 "매일 도는 자동화"의 최소 골격이다.

10핵심 키워드 사전

"이 문서와 저장소를 오가며 계속 나오는 말들"
용어
ContentItem출처가 무엇이든 모든 항목이 변환되는 단일 자료형. 제목·URL·본문·작성자·발행시각·프로파일·메타데이터를 담는다. 이것 덕분에 뒤쪽 파이프라인이 출처를 몰라도 된다
프로파일"이런 종류의 글은 이렇게 다뤄라"를 담은 폴더. match.md(해당 여부)·analysis.md(점수 기준)·enrichment.md(보강 지시)·profile.json(구조)로 구성
보강 블록 (enrichment block)브리핑 항목에 붙는 문단 단위. profile.jsonid·type·tools·primary·optional로 선언한다. 예: summary는 도구 없이, backgroundweb_search를 써서 생성
도구 화이트리스트보강 블록마다 쓸 수 있는 도구를 미리 못 박는 방식. 요약 블록이 멋대로 웹 검색을 하지 못하게 막아 비용과 결과를 예측 가능하게 만든다. 현재 구현된 도구는 web_search(덕덕고, 건당 최대 3건) 하나뿐이고, 프로파일이 없는 도구 이름을 적으면 시작 시점에 즉시 실패한다
임계값 (threshold)프로파일별 통과 점수 하한. processing.profile_settings에 둔다. 이 숫자 하나가 발행 개수와 API 비용을 동시에 정한다
토픽 중복 제거URL이 달라도 같은 사건이면 묶는 AI 단계. 살아남는 항목에 버려지는 항목의 댓글을 합쳐 정보 유실을 막는다
균형 다이제스트한 카테고리가 브리핑을 독식하지 않도록 카테고리별 개수 상한을 두는 기능. 카테고리는 출처 설정의 category에서 온다
시간창 (time window)collection.time_window_hours(기본 24). "지금부터 몇 시간 전까지의 글을 볼 것인가". Horizon은 '이미 본 항목' 목록을 따로 남기지 않으므로, 중복 방지가 사실상 이 값에 달려 있다
폴백 사다리1차 경로가 막히면 2·3차로 내려가는 구조. Reddit(HTML→JSON→RSS)·텔레그램(도메인 3개)이 대표 사례
SSRF서버가 공격자가 지정한 주소로 요청을 보내게 만드는 취약점. 방어는 이름 검사 + DNS 해석 IP 검사 + 리다이렉트 홉마다 재검증
MCPModel Context Protocol. AI 클라이언트가 외부 도구를 표준 방식으로 호출하는 규약. Horizon은 파이프라인 각 단계를 hz_* 도구로 노출한다
uvAstral이 만든 빠른 파이썬 패키지 관리자. uv sync로 잠금 파일 기준 환경을 재현한다

11참고 링크

"원문과 배경 기술"

프로젝트
· 저장소: github.com/Thysrael/Horizon
· 라이브 데모(일일 브리핑): thysrael.github.io/Horizon
· 공식 사이트 · 출처 허브: horizon1123.top

저장소 안 문서
· 설정 전반: docs/configuration.md
· 프로파일 구조: docs/profiles.md · 점수 기준: docs/scoring.md
· 수집기별 설정: docs/scrapers.md · 본문 추출기: docs/extractors.md
· MCP 연동: src/mcp/README.md

이 문서에서 특히 볼 만한 소스 파일
· 파이프라인 전체 흐름: src/orchestrator.pyrun()
· 수집기 계약(47줄): src/scrapers/base.py
· 폴백 사다리 실물: src/scrapers/reddit.py · src/scrapers/telegram.py
· SSRF 방어: src/url_security.py
· 판단 기준(코드가 아닌 글): profiles/tech-news/analysis.md

배경 기술
· httpx: python-httpx.org · pydantic: docs.pydantic.dev
· uv: docs.astral.sh/uv · trafilatura: trafilatura.readthedocs.io
· MCP: modelcontextprotocol.io · GDELT DOC API: blog.gdeltproject.org