Files
homeclaw/tests/top-headlines.test.ts
T
kimandClaude Opus 5 6449126b46 feat: 넓은 뉴스 요청은 주요 매체 1면(RSS)에서 가져온다
사용자 지적: "뭔가 이상한데" — 매체를 좁힌 뒤에도 함부르크 아파트 균열,
패러글라이딩 실종자 수색, "하비에르 바르뎀은 친근한 배우" 같은 기사가
"주요 뉴스"로 올라왔다.

원인은 매체가 아니라 정렬이었다. NewsData /latest는 시간 역순이고, 주요지도
하루에 수백 건을 내므로 **가장 최근이 가장 중요한 것은 아니다.** 매체 제한은
바닥(물병 광고)은 올렸지만 천장은 못 올렸다. API 자체 손잡이(category=top,
prioritydomain=top)는 앞서 전부 실측했고 소용없었다.

신문 1면이 빠진 신호다. 사람 편집자가 이미 하루를 정렬해 놓은 유일한 곳이고,
주요지는 전부 RSS로 공개한다. 같은 시각 같은 요청의 결과:

  NewsData(매체제한)  함부르크 아파트 균열 / 카탈루냐 패러글라이딩 / 바르뎀 호평
  1면 RSS             콜롬비아 지진 180명 사망 / 트럼프 앙카라 비밀 탈출 /
                      미네소타 경선 결과 / 스페인의 대이탈리아 국경 검문

- 넓은 요청(query 없고 category 없거나 top)에만 적용. 주제를 지정한 요청은
  NewsData 전체 풀 그대로다 — 실측 확인: query="crime"은 여전히 지역지 기사까지
  들어온다. 1면은 정의상 그날의 톱이라 특정 주제를 물으면 답이 안 나온다
- 다국가는 라운드로빈으로 병합. 발행량 많은 1면 하나가 나머지를 밀어내면 안 된다
- 피드 실패는 조용히 건너뛰고, 전부 실패하면 NewsData로 폴백한다
- 피드는 전부 라이브 확인 후 추가. 한국은 제외했다 — 연합/한겨레/경향/JTBC를
  다 확인했으나 전부 시간순이고 편집 정렬된 1면 피드가 없어, kr은 기존
  주요매체 경로를 그대로 쓴다

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 13:57:02 +09:00

81 lines
3.1 KiB
TypeScript

/**
* top-headlines — 1면 RSS 파싱
*
* 순수 함수만 테스트한다. 피드 수신은 네트워크에 의존하므로 다루지 않는다.
*
* 이 파서가 조용히 깨지면 "뉴스가 없는 날"처럼 보인다 — 남의 문서 구조에 기대는 코드라
* 언젠가 바뀐다. 저장된 조각으로 회귀를 잡는 게 이 테스트의 목적이다.
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { parseFeedItems, hasTopHeadlineFeeds, formatHeadlines } from '../src/tools/top-headlines';
const RSS = `<?xml version="1.0"?><rss><channel>
<title>NYT &gt; Top Stories</title>
<item><title>Lisa Demuth Wins Republican Primary</title><link>https://ex.invalid/a</link></item>
<item><title><![CDATA[Rescuers scramble: 180 dead & counting]]></title><link>https://ex.invalid/b</link></item>
</channel></rss>`;
const ATOM = `<?xml version="1.0"?><feed>
<entry><title>Front page story</title><link rel="alternate" href="https://ex.invalid/c"/></entry>
</feed>`;
describe('parseFeedItems', () => {
test('RSS item에서 제목과 링크를 뽑는다', () => {
const items = parseFeedItems(RSS, 'nytimes.com');
assert.equal(items.length, 2);
assert.equal(items[0].title, 'Lisa Demuth Wins Republican Primary');
assert.equal(items[0].link, 'https://ex.invalid/a');
assert.equal(items[0].source, 'nytimes.com');
});
test('채널 제목은 기사로 세지 않는다', () => {
assert.ok(!parseFeedItems(RSS, 'x').some(i => i.title.includes('Top Stories')));
});
test('CDATA와 엔티티를 푼다', () => {
assert.equal(parseFeedItems(RSS, 'x')[1].title, 'Rescuers scramble: 180 dead & counting');
});
test('Atom entry도 읽는다 — link는 href 속성에 있다', () => {
const items = parseFeedItems(ATOM, 'x');
assert.equal(items[0].title, 'Front page story');
assert.equal(items[0].link, 'https://ex.invalid/c');
});
test('limit을 지킨다', () => {
assert.equal(parseFeedItems(RSS, 'x', 1).length, 1);
});
test('구조가 바뀌면 0건 — 호출부가 NewsData로 폴백할 신호', () => {
assert.deepEqual(parseFeedItems('<html>완전히 다른 문서</html>', 'x'), []);
assert.deepEqual(parseFeedItems('', 'x'), []);
});
});
describe('hasTopHeadlineFeeds', () => {
test('피드가 있는 나라', () => {
for (const c of ['us', 'gb', 'de', 'fr', 'it', 'es']) assert.equal(hasTopHeadlineFeeds(c), true, c);
});
test('다국가 목록은 하나라도 있으면 참', () => {
assert.equal(hasTopHeadlineFeeds('gb,de,fr,it,es'), true);
assert.equal(hasTopHeadlineFeeds('jp,de'), true);
});
test('피드가 없는 나라 — NewsData 경로로 간다', () => {
assert.equal(hasTopHeadlineFeeds('kr'), false);
assert.equal(hasTopHeadlineFeeds('jp'), false);
assert.equal(hasTopHeadlineFeeds(''), false);
});
});
describe('formatHeadlines', () => {
test('번호·링크·출처를 담는다', () => {
const out = formatHeadlines(parseFeedItems(RSS, 'nytimes.com'));
assert.match(out, /^\[1\] Lisa Demuth/);
assert.match(out, /Source: nytimes\.com/);
});
});