mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Add naver-news-search skill and /v1/naver-news/search proxy route
Closes #143. Proxies the official Naver Search Open API news endpoint (openapi.naver.com/v1/search/news.json) through k-skill-proxy so users do not need to issue their own Naver Client ID/Secret. Reuses the existing NAVER_SEARCH_CLIENT_ID/NAVER_SEARCH_CLIENT_SECRET that naver-shopping already consumes, since the Naver Developer application enables the 'Search' scope covering both news and shopping. Implementation details: - src/naver-news.js normalizes q/display/start/sort, builds the official URL, calls upstream with X-Naver-Client-Id/Secret headers, and parses the JSON response into rank/title/description/link/original_link/pub_date items. - Strips <b> highlight tags and decodes HTML entities in title/description using zero-width replacement so compound Korean words like '주식형' are preserved (not split into '주식 형'). - Parses RFC822 pubDate into pub_date_iso (ISO-8601 UTC) for clients. - Deduplicates items by normalized link; drops entries missing title/link. - Returns 503 upstream_not_configured when proxy keys are absent (no public BFF fallback exists for news like it does for shopping, so keys are required). - Failure responses are not cached (failure-aware cache layer). - Exposes naverNewsApiConfigured on /health. 14 new tests in test/naver-news.test.js cover query validation, URL building, payload normalization (HTML stripping, entity decoding, deduplication, missing-field tolerance), plus Fastify integration tests for 200/400/401/429/500/503 paths, cache hit/miss, header wiring, and the health flag.
This commit is contained in:
parent
9d7da7bb8d
commit
4c7877a5c9
9 changed files with 1098 additions and 5 deletions
5
.changeset/naver-news-search.md
Normal file
5
.changeset/naver-news-search.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
"k-skill-proxy": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add `/v1/naver-news/search` route plus matching `naver-news-search` skill. Proxies the official Naver Search Open API news endpoint (`openapi.naver.com/v1/search/news.json`), reuses the existing `NAVER_SEARCH_CLIENT_ID`/`NAVER_SEARCH_CLIENT_SECRET` credentials, and keeps the user-facing credential surface empty ("불필요"). Strips `<b>` highlight tags and decodes HTML entities in titles/descriptions, parses RFC822 `pubDate` into ISO-8601, deduplicates results by `link`, caches successes for 5 minutes (failures are not cached), and exposes `naverNewsApiConfigured` on `/health`. Closes #143.
|
||||||
10
README.md
10
README.md
|
|
@ -52,7 +52,9 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
||||||
| 토스증권 조회 | 토스증권 계좌 요약, 포트폴리오, 시세, 주문내역, 관심종목 조회 | 필요 | [토스증권 조회 가이드](docs/features/toss-securities.md) |
|
| 토스증권 조회 | 토스증권 계좌 요약, 포트폴리오, 시세, 주문내역, 관심종목 조회 | 필요 | [토스증권 조회 가이드](docs/features/toss-securities.md) |
|
||||||
| 하이패스 영수증 발급 | 하이패스 사용내역 조회 및 영수증 출력 payload 준비 | 필요 | [하이패스 영수증 발급 가이드](docs/features/hipass-receipt.md) |
|
| 하이패스 영수증 발급 | 하이패스 사용내역 조회 및 영수증 출력 payload 준비 | 필요 | [하이패스 영수증 발급 가이드](docs/features/hipass-receipt.md) |
|
||||||
| 로또 당첨 확인 | 로또 최신 회차, 특정 회차, 번호 대조 | 불필요 | [로또 결과 가이드](docs/features/lotto-results.md) |
|
| 로또 당첨 확인 | 로또 최신 회차, 특정 회차, 번호 대조 | 불필요 | [로또 결과 가이드](docs/features/lotto-results.md) |
|
||||||
| HWP 문서 처리 | `.hwp/.hwpx` → Markdown/JSON 변환, 문서 비교, 양식 필드 추출, Markdown→HWPX 역변환 | 불필요 | [HWP 문서 처리 가이드](docs/features/hwp.md) |
|
| HWP 문서 조회/변환 | `.hwp/.hwpx` → Markdown/JSON 변환, 문서 비교, 양식 필드 추출, Markdown→HWPX 역변환 (kordoc 기반 read-only) | 불필요 | [HWP 문서 처리 가이드](docs/features/hwp.md) |
|
||||||
|
| HWP 문서 편집 | `.hwp` 본문 텍스트 삽입/삭제, 표 생성, 셀 수정, replace-all (`k-skill-rhwp` CLI + `@rhwp/core` WASM, HWP 5.x round-trip) | 불필요 | [HWP 문서 편집 가이드](docs/features/rhwp-edit.md) |
|
||||||
|
| HWP 레이아웃·IR 디버깅 | 업스트림 `rhwp` Rust CLI(`export-svg --debug-overlay`, `dump`, `dump-pages`, `ir-diff`, `thumbnail`, `convert`)로 HWP 레이아웃 진단·IR 덤프·버전 비교·썸네일 추출·배포용 문서 잠금 해제 | 불필요 | [HWP 레이아웃·IR 디버깅 가이드](docs/features/rhwp-advanced.md) |
|
||||||
| ~~근처 블루리본 맛집~~ ⚠️ 지원 중단 | ~~현재 위치 기준 근처 블루리본 선정 맛집 조회~~ | ~~불필요~~ | ~~[근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md)~~ |
|
| ~~근처 블루리본 맛집~~ ⚠️ 지원 중단 | ~~현재 위치 기준 근처 블루리본 선정 맛집 조회~~ | ~~불필요~~ | ~~[근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md)~~ |
|
||||||
| 근처 술집 조회 | 현재 위치 기준 영업 상태·메뉴·좌석·전화번호가 포함된 근처 술집 조회 | 불필요 | [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md) |
|
| 근처 술집 조회 | 현재 위치 기준 영업 상태·메뉴·좌석·전화번호가 포함된 근처 술집 조회 | 불필요 | [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md) |
|
||||||
| 우편번호 검색 | 주소 키워드로 우편번호 + 공식 영문주소 조회 | 불필요 | [우편번호 검색 가이드](docs/features/zipcode-search.md) |
|
| 우편번호 검색 | 주소 키워드로 우편번호 + 공식 영문주소 조회 | 불필요 | [우편번호 검색 가이드](docs/features/zipcode-search.md) |
|
||||||
|
|
@ -67,6 +69,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
||||||
| 한국어 맞춤법 검사 | 한국어 텍스트 맞춤법/문법 검사 및 교정안 정리 | 불필요 | [한국어 맞춤법 검사 가이드](docs/features/korean-spell-check.md) |
|
| 한국어 맞춤법 검사 | 한국어 텍스트 맞춤법/문법 검사 및 교정안 정리 | 불필요 | [한국어 맞춤법 검사 가이드](docs/features/korean-spell-check.md) |
|
||||||
| 네이버 블로그 리서치 | 네이버 블로그 검색, 원문 읽기, 이미지 다운로드, 한국어 콘텐츠 교차 검증 | 불필요 | [네이버 블로그 리서치 가이드](docs/features/naver-blog-research.md) |
|
| 네이버 블로그 리서치 | 네이버 블로그 검색, 원문 읽기, 이미지 다운로드, 한국어 콘텐츠 교차 검증 | 불필요 | [네이버 블로그 리서치 가이드](docs/features/naver-blog-research.md) |
|
||||||
| 네이버 쇼핑 가격비교 | 네이버 검색 Open API 우선, 공개 BFF JSON fallback으로 상품 후보·현재 노출가·판매처 링크 비교 | 불필요 | [네이버 쇼핑 가격비교 가이드](docs/features/naver-shopping-search.md) |
|
| 네이버 쇼핑 가격비교 | 네이버 검색 Open API 우선, 공개 BFF JSON fallback으로 상품 후보·현재 노출가·판매처 링크 비교 | 불필요 | [네이버 쇼핑 가격비교 가이드](docs/features/naver-shopping-search.md) |
|
||||||
|
| 네이버 뉴스 검색 | 네이버 검색 Open API 뉴스 검색으로 기사 제목·요약·발행시각·원문/네이버 링크를 정리 | 불필요 | [네이버 뉴스 검색 가이드](docs/features/naver-news-search.md) |
|
||||||
| 한국어 글자 수 세기 | 한국어 텍스트의 글자 수·줄 수·UTF-8/NEIS byte 수를 결정론적으로 계산 | 불필요 | [한국어 글자 수 세기 가이드](docs/features/korean-character-count.md) |
|
| 한국어 글자 수 세기 | 한국어 텍스트의 글자 수·줄 수·UTF-8/NEIS byte 수를 결정론적으로 계산 | 불필요 | [한국어 글자 수 세기 가이드](docs/features/korean-character-count.md) |
|
||||||
|
|
||||||
> ## ⚠️ 근처 블루리본 맛집 스킬 — 지원 중단
|
> ## ⚠️ 근처 블루리본 맛집 스킬 — 지원 중단
|
||||||
|
|
@ -131,7 +134,9 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
||||||
- [토스증권 조회 가이드](docs/features/toss-securities.md)
|
- [토스증권 조회 가이드](docs/features/toss-securities.md)
|
||||||
- [하이패스 영수증 발급 가이드](docs/features/hipass-receipt.md)
|
- [하이패스 영수증 발급 가이드](docs/features/hipass-receipt.md)
|
||||||
- [로또 당첨 확인](docs/features/lotto-results.md)
|
- [로또 당첨 확인](docs/features/lotto-results.md)
|
||||||
- [HWP 문서 처리](docs/features/hwp.md)
|
- [HWP 문서 조회/변환](docs/features/hwp.md)
|
||||||
|
- [HWP 문서 편집](docs/features/rhwp-edit.md)
|
||||||
|
- [HWP 레이아웃·IR 디버깅](docs/features/rhwp-advanced.md)
|
||||||
- [근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md)
|
- [근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md)
|
||||||
- [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md)
|
- [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md)
|
||||||
- [우편번호 검색](docs/features/zipcode-search.md)
|
- [우편번호 검색](docs/features/zipcode-search.md)
|
||||||
|
|
@ -146,6 +151,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
||||||
- [한국어 맞춤법 검사 가이드](docs/features/korean-spell-check.md)
|
- [한국어 맞춤법 검사 가이드](docs/features/korean-spell-check.md)
|
||||||
- [네이버 블로그 리서치 가이드](docs/features/naver-blog-research.md)
|
- [네이버 블로그 리서치 가이드](docs/features/naver-blog-research.md)
|
||||||
- [네이버 쇼핑 가격비교 가이드](docs/features/naver-shopping-search.md)
|
- [네이버 쇼핑 가격비교 가이드](docs/features/naver-shopping-search.md)
|
||||||
|
- [네이버 뉴스 검색 가이드](docs/features/naver-news-search.md)
|
||||||
- [한국어 글자 수 세기 가이드](docs/features/korean-character-count.md)
|
- [한국어 글자 수 세기 가이드](docs/features/korean-character-count.md)
|
||||||
- [릴리스/배포 가이드](docs/releasing.md)
|
- [릴리스/배포 가이드](docs/releasing.md)
|
||||||
|
|
||||||
|
|
|
||||||
137
docs/features/naver-news-search.md
Normal file
137
docs/features/naver-news-search.md
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
---
|
||||||
|
title: 네이버 뉴스 검색 가이드
|
||||||
|
description: k-skill-proxy 경유 네이버 검색 Open API 뉴스 검색으로 최신 기사 제목/요약/링크를 조회하는 방법
|
||||||
|
---
|
||||||
|
|
||||||
|
# 네이버 뉴스 검색 가이드
|
||||||
|
|
||||||
|
## 이 기능으로 할 수 있는 일
|
||||||
|
|
||||||
|
- 검색어 기반 네이버 뉴스 기사 후보 목록 조회
|
||||||
|
- 기사 제목, 본문 요약, 원문 링크, 네이버 뉴스 링크 정리
|
||||||
|
- 발행 시각(ISO-8601) 기준 최신순/관련도순 정렬
|
||||||
|
- `<b>` 하이라이트 태그와 HTML entity 를 proxy 쪽에서 제거한 깨끗한 텍스트 사용
|
||||||
|
|
||||||
|
## 가장 중요한 규칙
|
||||||
|
|
||||||
|
기본 경로는 `https://k-skill-proxy.nomadamas.org/v1/naver-news/search` 이다. 사용자는 **네이버 개발자 센터 Client ID/Secret 을 발급받을 필요가 없다**. upstream key(`NAVER_SEARCH_CLIENT_ID`/`NAVER_SEARCH_CLIENT_SECRET`)는 프록시 서버에서만 주입한다.
|
||||||
|
|
||||||
|
네이버 검색 Open API 는 전체 검색 카테고리(뉴스/블로그/쇼핑 등) 를 합쳐 **하루 25,000 호출** 제한이 있다. 재시도 루프로 낭비하지 않는다.
|
||||||
|
|
||||||
|
본 스킬은 **기사 메타데이터와 요약만** 다룬다. 기사 본문 전체, 유료 기사, 로그인 필요 기사는 다루지 않는다.
|
||||||
|
|
||||||
|
## 먼저 필요한 것
|
||||||
|
|
||||||
|
- 인터넷 연결
|
||||||
|
- `curl` 또는 HTTP 호출이 가능한 도구
|
||||||
|
- (프록시 운영자 전용) `NAVER_SEARCH_CLIENT_ID`, `NAVER_SEARCH_CLIENT_SECRET` 환경변수 - 네이버 개발자 센터(https://developers.naver.com/apps/#/register)에서 "검색" 권한 애플리케이션을 등록하고 발급받는다
|
||||||
|
|
||||||
|
## 지원 엔드포인트
|
||||||
|
|
||||||
|
| Route | 설명 |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /v1/naver-news/search` | 네이버 뉴스 검색. 제목/요약/링크/발행시각 정규화 |
|
||||||
|
|
||||||
|
### `/v1/naver-news/search` 파라미터
|
||||||
|
|
||||||
|
| 파라미터 | 타입 | 기본값 | 설명 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `q` / `query` / `keyword` | string | (필수) | 검색어. 2글자 이상 |
|
||||||
|
| `display` / `limit` / `size` | int | 10 | 반환 건수. 1 ~ 100 으로 clamp |
|
||||||
|
| `start` / `offset` | int | 1 | 검색 시작 위치(1-indexed). 최대 1000 |
|
||||||
|
| `sort` | string | `sim` | `sim`(관련도순) 또는 `date`(최신순). 그 외 값은 `sim` fallback |
|
||||||
|
|
||||||
|
## 기본 호출
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/naver-news/search' \
|
||||||
|
--data-urlencode 'q=삼성전자 실적' \
|
||||||
|
--data-urlencode 'display=10' \
|
||||||
|
--data-urlencode 'sort=date'
|
||||||
|
```
|
||||||
|
|
||||||
|
로컬 proxy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS --get 'http://127.0.0.1:4020/v1/naver-news/search' \
|
||||||
|
--data-urlencode 'q=인공지능 규제' \
|
||||||
|
--data-urlencode 'display=5'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 응답 예시
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"rank": 1,
|
||||||
|
"title": "삼성전자 1분기 실적 발표",
|
||||||
|
"description": "삼성전자가 올해 1분기 실적을 발표했다. 영업이익은 전년 동기 대비 증가했다.",
|
||||||
|
"link": "https://n.news.naver.com/mnews/article/001/samsung",
|
||||||
|
"original_link": "https://news.example.com/samsung",
|
||||||
|
"pub_date": "Mon, 22 Apr 2026 09:30:00 +0900",
|
||||||
|
"pub_date_iso": "2026-04-22T00:30:00.000Z",
|
||||||
|
"source": "naver-openapi"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"query": {
|
||||||
|
"q": "삼성전자 실적",
|
||||||
|
"display": 10,
|
||||||
|
"start": 1,
|
||||||
|
"sort": "date"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"query": "삼성전자 실적",
|
||||||
|
"extraction": "naver-openapi",
|
||||||
|
"item_count": 1,
|
||||||
|
"total": 1234567,
|
||||||
|
"start": 1,
|
||||||
|
"display": 1,
|
||||||
|
"last_build_date": "Mon, 22 Apr 2026 10:00:00 +0900",
|
||||||
|
"sort": "date"
|
||||||
|
},
|
||||||
|
"upstream": {
|
||||||
|
"url": "https://openapi.naver.com/v1/search/news.json?query=...&display=10&start=1&sort=date",
|
||||||
|
"status_code": 200,
|
||||||
|
"content_type": "application/json;charset=UTF-8",
|
||||||
|
"provider": "naver-search-api"
|
||||||
|
},
|
||||||
|
"proxy": {
|
||||||
|
"name": "k-skill-proxy",
|
||||||
|
"cache": { "hit": false, "ttl_ms": 300000 },
|
||||||
|
"requested_at": "2026-04-22T01:00:00.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 기본 흐름
|
||||||
|
|
||||||
|
1. 사용자 검색어를 확인한다. 없거나 2글자 미만이면 먼저 물어본다.
|
||||||
|
2. "최신순" 요청이면 `sort=date`, 그 외는 `sort=sim` 으로 호출한다.
|
||||||
|
3. 상위 3~5건을 제목·발행시각(KST)·요약·링크로 정리해 보여준다.
|
||||||
|
4. `original_link` 가 있으면 원문 링크를 우선 노출하고, 없으면 `link`(네이버 뉴스 redirect)를 안내한다.
|
||||||
|
5. `items` 가 비었거나 upstream 오류가 발생하면 재시도하지 말고 검색어를 좁혀 다시 물어본다.
|
||||||
|
|
||||||
|
## 실패 모드
|
||||||
|
|
||||||
|
- `400 bad_request`: 검색어 누락, 2글자 미만. 메시지를 그대로 사용자에게 노출한다.
|
||||||
|
- `503 upstream_not_configured`: 프록시 서버에 `NAVER_SEARCH_CLIENT_ID`/`NAVER_SEARCH_CLIENT_SECRET` 가 없는 경우. 운영자가 키를 등록해야 한다.
|
||||||
|
- `401 upstream_error` (`errorCode: 024`): 프록시 서버의 Client ID/Secret 잘못됨. 운영자 재발급 필요.
|
||||||
|
- `429 upstream_error` (`errorCode: 010`): 네이버 검색 API 일일 쿼터(25,000 호출/일) 초과. 재시도 루프 금지. 잠시 후 다시 시도.
|
||||||
|
- `502 upstream_error`: 네이버 API 5xx 또는 JSON 파싱 실패.
|
||||||
|
- proxy 는 upstream 실패 응답을 **캐시하지 않는다**(failure-aware cache). 다음 요청이 온전히 upstream 을 한 번 더 탄다.
|
||||||
|
|
||||||
|
## 운영 팁
|
||||||
|
|
||||||
|
- 사용자 요구가 "오늘", "최신" 이면 `sort=date` 로 호출하는 것이 보통 더 만족스럽다.
|
||||||
|
- `display` 가 클수록 네이버 API 쿼터를 빨리 소모한다. 기본 10 에서 벗어날 필요 없는 경우가 많다.
|
||||||
|
- `start + display` 조합이 1000 을 넘는 위치는 네이버 API 가 결과를 돌려주지 않는다. 아주 오래된 기사를 찾을 때는 검색어를 좁히는 것이 낫다.
|
||||||
|
- `pub_date` 는 RFC822 형식, `pub_date_iso` 는 UTC ISO-8601 이다. 사용자에게 보여줄 때는 KST(UTC+9) 로 변환한다.
|
||||||
|
- proxy route 는 public/read-only/no-auth 이며 5분 캐시 + 분당 60 회 rate limit 으로 남용을 막는다.
|
||||||
|
- 기사 원문 풀텍스트가 필요하면 이 스킬로는 얻을 수 없다. 사용자가 링크를 직접 방문하도록 안내한다.
|
||||||
|
|
||||||
|
## 출처/참고
|
||||||
|
|
||||||
|
- 네이버 검색 API 뉴스 검색 문서: https://developers.naver.com/docs/serviceapi/search/news/news.md
|
||||||
|
- 네이버 개발자 센터: https://developers.naver.com
|
||||||
|
- 레퍼런스 오픈소스: `isnow890/naver-search-mcp`, `kiyeonjeon21/naver-cli`
|
||||||
113
naver-news-search/SKILL.md
Normal file
113
naver-news-search/SKILL.md
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
---
|
||||||
|
name: naver-news-search
|
||||||
|
description: 네이버 검색 Open API 뉴스 검색(news.json)을 k-skill-proxy 경유로 조회해 최신 뉴스 기사 제목·발행시각·링크·요약을 보수적으로 정리한다. 사용자는 별도 API 키 발급 없이 호출한다.
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
category: information
|
||||||
|
locale: ko-KR
|
||||||
|
phase: v1
|
||||||
|
---
|
||||||
|
|
||||||
|
# Naver News Search
|
||||||
|
|
||||||
|
## What this skill does
|
||||||
|
|
||||||
|
`k-skill-proxy`가 네이버 검색 Open API 뉴스 검색(`openapi.naver.com/v1/search/news.json`)을 호출해 최근 뉴스 기사 후보를 정규화된 JSON 으로 돌려준다.
|
||||||
|
|
||||||
|
- 검색어 기반 최신 뉴스 후보 목록을 정리한다.
|
||||||
|
- 기사 제목, 본문 요약(description), 발행 시각(`pub_date`/`pub_date_iso`), 네이버 뉴스 링크(`link`), 원문 링크(`original_link`)를 제공한다.
|
||||||
|
- Naver 가 응답에 섞어주는 `<b>` 하이라이트 태그와 HTML entity(`&`, `"`, `<` 등)는 proxy 쪽에서 미리 제거한다.
|
||||||
|
- 사용자 로그인·개인화·회원 전용 뉴스는 지원하지 않는다.
|
||||||
|
|
||||||
|
## When to use
|
||||||
|
|
||||||
|
- "오늘 삼성전자 관련 뉴스 찾아줘"
|
||||||
|
- "최근 AI 규제 관련 기사 최신순으로 5개만"
|
||||||
|
- "네이버 뉴스에서 금리 인상 기사 요약해줘"
|
||||||
|
- "이 사건 기사 링크 정리해줘"
|
||||||
|
|
||||||
|
## When not to use
|
||||||
|
|
||||||
|
- 특정 언론사 내부 유료 기사, 로그인 뒤에만 보이는 기사
|
||||||
|
- 기사 본문 전체가 필요한 경우 (API 는 요약 description 만 제공)
|
||||||
|
- 주식/환율/부동산 실시간 시세 (뉴스 API 는 기사만 다룬다)
|
||||||
|
- 차단/CAPTCHA 우회가 필요한 경로
|
||||||
|
|
||||||
|
## Required inputs
|
||||||
|
|
||||||
|
검색어(`q` / `query`)가 없으면 먼저 물어본다.
|
||||||
|
|
||||||
|
권장 질문:
|
||||||
|
|
||||||
|
> 찾을 네이버 뉴스 검색어를 알려주세요. 예: "삼성전자 실적", "인공지능 규제", "금리 인상"
|
||||||
|
|
||||||
|
단어 2글자 미만이면 의미가 불분명하므로 되묻는다.
|
||||||
|
|
||||||
|
## Proxy endpoint
|
||||||
|
|
||||||
|
기본값은 public/read-only/no-auth 프록시다. 사용자는 **네이버 개발자 센터 Client ID/Secret 을 발급받지 않아도 된다**. upstream key(`NAVER_SEARCH_CLIENT_ID` / `NAVER_SEARCH_CLIENT_SECRET`)는 프록시 서버에서만 주입한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS --get "${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}/v1/naver-news/search" \
|
||||||
|
--data-urlencode 'q=삼성전자 실적' \
|
||||||
|
--data-urlencode 'display=10' \
|
||||||
|
--data-urlencode 'sort=date'
|
||||||
|
```
|
||||||
|
|
||||||
|
쿼리 파라미터:
|
||||||
|
|
||||||
|
- `q` 또는 `query` — 검색어. 2글자 이상.
|
||||||
|
- `display` — 반환 건수. 기본 10, 범위 1~100.
|
||||||
|
- `start` — 검색 시작 위치(1-indexed). 기본 1, 최대 1000. `start + display` 는 네이버 API 상 최대 1000 까지만 접근 가능하다.
|
||||||
|
- `sort` — `sim`(유사도 순, 기본값) 또는 `date`(최신순). 그 외 값은 `sim` 으로 fallback.
|
||||||
|
|
||||||
|
응답 주요 필드:
|
||||||
|
|
||||||
|
- `items[].title` — `<b>` 태그·HTML entity 가 제거된 기사 제목
|
||||||
|
- `items[].description` — `<b>` 태그·HTML entity 가 제거된 기사 요약
|
||||||
|
- `items[].link` — 네이버 뉴스 redirect 링크
|
||||||
|
- `items[].original_link` — 원문 뉴스 링크(빈 문자열이면 `null`)
|
||||||
|
- `items[].pub_date` — 원본 RFC822 형식 발행 시각
|
||||||
|
- `items[].pub_date_iso` — 파싱된 ISO-8601(UTC) 발행 시각. 파싱 실패시 `null`
|
||||||
|
- `meta.extraction` — 항상 `naver-openapi`
|
||||||
|
- `meta.total`, `meta.start`, `meta.display`, `meta.last_build_date`, `meta.sort`
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. 검색어를 확인한다. (없거나 2글자 미만이면 먼저 물어본다)
|
||||||
|
2. 사용자가 "최신순"을 원하면 `sort=date`, 그 외에는 `sort=sim` 으로 호출한다.
|
||||||
|
3. `GET /v1/naver-news/search` 를 호출한다.
|
||||||
|
4. `items` 가 있으면 상위 3~5건을 제목, 발행 시각(KST 기준으로 재포맷해도 좋다), 요약, 링크로 짧게 정리한다.
|
||||||
|
5. 발행 시각은 `pub_date_iso` 기준으로 오늘/어제 표기를 붙여도 된다. (KST = UTC+9)
|
||||||
|
6. `items` 가 비었거나 `upstream_error` 가 나면 재시도하지 말고 검색어를 좁혀 다시 물어본다.
|
||||||
|
|
||||||
|
## Response style
|
||||||
|
|
||||||
|
- 기사 제목/요약은 API 가 돌려준 원문만 인용한다. 원문에 없는 해설은 덧붙이지 않는다.
|
||||||
|
- 기사 발행 시각은 "KST 기준 {YYYY-MM-DD HH:mm}" 또는 "{n}시간 전" 정도로 짧게 표시한다.
|
||||||
|
- 원문 링크(`original_link`)가 있으면 우선 노출하고, 없으면 `link`(네이버 뉴스 redirect)를 안내한다.
|
||||||
|
- 서로 다른 언론사가 같은 사건을 다루면 링크 2~3개를 병렬로 제시해 사용자가 비교할 수 있게 한다.
|
||||||
|
- `description` 은 요약이므로, 팩트로 단정하지 말고 "기사 요약에 따르면"이라고 전한다.
|
||||||
|
|
||||||
|
## Failure modes
|
||||||
|
|
||||||
|
- `400 bad_request` — 검색어 누락, 2글자 미만, 허용되지 않는 파라미터. 에러 메시지를 그대로 사용자에게 노출한다.
|
||||||
|
- `503 upstream_not_configured` — 프록시 서버에 `NAVER_SEARCH_CLIENT_ID`/`NAVER_SEARCH_CLIENT_SECRET` 가 없는 경우. 운영자가 키를 등록해야 한다. 사용자에게는 "잠시 후 다시 시도해 주세요" 정도로 안내한다.
|
||||||
|
- `401 upstream_error` — 프록시 서버의 Client ID/Secret 이 잘못된 경우(`errorCode: 024`). 운영자가 재발급해야 한다.
|
||||||
|
- `429 upstream_error` — 네이버 검색 API 일일 쿼터(25,000 호출/일) 초과(`errorCode: 010`). 재시도 루프는 금지. 잠시 후 다시 시도하도록 안내한다.
|
||||||
|
- `502 upstream_error` — 네이버 API 5xx 또는 응답 JSON 파싱 실패.
|
||||||
|
- upstream 차단이나 장애 발생 시 재시도하지 않는다. cache + rate limit 만으로 대응하고, 사용자에게는 현재 조회 불가능함을 분명히 말한다.
|
||||||
|
|
||||||
|
## Privacy
|
||||||
|
|
||||||
|
- 검색어/결과를 영구 저장하지 않는다.
|
||||||
|
- 기사 본문은 요청하지 않는다. description(API 가 주는 요약)만 사용한다.
|
||||||
|
- 특정 인물·사건을 비방·추측하는 서술은 하지 않는다. 기사 원문만 전달한다.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- 검색어를 확인했다.
|
||||||
|
- 최소 1건 이상의 기사를 제목·요약·발행 시각·링크로 정리해서 돌려주거나, 왜 결과가 없는지 설명했다.
|
||||||
|
- 발행 시각은 KST 기준으로 표시했다.
|
||||||
|
- 네이버 API 쿼터 상태·차단 발생 여부·재시도 금지 원칙을 지켰다.
|
||||||
|
- 로그인/개인화/차단 우회 범위를 벗어나지 않았다.
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
- `GET /v1/korean-stock/base-info`
|
- `GET /v1/korean-stock/base-info`
|
||||||
- `GET /v1/korean-stock/trade-info`
|
- `GET /v1/korean-stock/trade-info`
|
||||||
- `GET /v1/naver-shopping/search` — 네이버 검색 Open API 쇼핑 검색 우선, 키가 없으면 네이버 쇼핑 공개 BFF JSON 기반 상품/가격 후보 조회
|
- `GET /v1/naver-shopping/search` — 네이버 검색 Open API 쇼핑 검색 우선, 키가 없으면 네이버 쇼핑 공개 BFF JSON 기반 상품/가격 후보 조회
|
||||||
|
- `GET /v1/naver-news/search` — 네이버 검색 Open API 뉴스 검색(`news.json`) 기반 최신 뉴스 기사 제목/요약/링크/발행시각 조회(`NAVER_SEARCH_CLIENT_ID`, `NAVER_SEARCH_CLIENT_SECRET` 필요)
|
||||||
- `GET /v1/data4library/library-search` — 도서관 정보나루 정보공개 도서관 조회(`DATA4LIBRARY_AUTH_KEY`)
|
- `GET /v1/data4library/library-search` — 도서관 정보나루 정보공개 도서관 조회(`DATA4LIBRARY_AUTH_KEY`)
|
||||||
- `GET /v1/data4library/book-search` — 도서관 정보나루 도서 검색(`DATA4LIBRARY_AUTH_KEY`)
|
- `GET /v1/data4library/book-search` — 도서관 정보나루 도서 검색(`DATA4LIBRARY_AUTH_KEY`)
|
||||||
- `GET /v1/data4library/book-detail` — 도서관 정보나루 도서 상세 조회(`DATA4LIBRARY_AUTH_KEY`)
|
- `GET /v1/data4library/book-detail` — 도서관 정보나루 도서 상세 조회(`DATA4LIBRARY_AUTH_KEY`)
|
||||||
|
|
@ -37,7 +38,7 @@
|
||||||
- `DATA4LIBRARY_AUTH_KEY` — 프록시 서버 쪽 도서관 정보나루 Open API 인증키 (`data4library/*`)
|
- `DATA4LIBRARY_AUTH_KEY` — 프록시 서버 쪽 도서관 정보나루 Open API 인증키 (`data4library/*`)
|
||||||
- `FOODSAFETYKOREA_API_KEY` — 프록시 서버 쪽 식품안전나라 회수정보 live key (`mfds/food-safety/search`; 없으면 sample feed fallback)
|
- `FOODSAFETYKOREA_API_KEY` — 프록시 서버 쪽 식품안전나라 회수정보 live key (`mfds/food-safety/search`; 없으면 sample feed fallback)
|
||||||
- `KRX_API_KEY` — 프록시 서버 쪽 KRX Open API upstream key
|
- `KRX_API_KEY` — 프록시 서버 쪽 KRX Open API upstream key
|
||||||
- `NAVER_SEARCH_CLIENT_ID`, `NAVER_SEARCH_CLIENT_SECRET` — 선택: 네이버 검색 Open API 쇼핑 검색(`shop.json`) 키. 설정되면 네이버 쇼핑 route가 bot-block 위험이 낮은 공식 API를 우선 사용하고, 없으면 공개 BFF JSON(`ns-portal.shopping.naver.com/api/v2/shopping-paged-slot`) 파서로 fallback. 공식 API는 `review` 정렬을 지원하지 않아 `meta.sort_applied: "unsupported"`로 표시한다. no-key fallback은 `page`를 BFF에 전달해 해당 페이지를 고르고, `price_asc`/`price_dsc`/`review`는 선택 페이지 안에서 로컬 정렬하며, `date`는 `meta.sort_applied: "unsupported"`로 표시
|
- `NAVER_SEARCH_CLIENT_ID`, `NAVER_SEARCH_CLIENT_SECRET` — 네이버 검색 Open API 키(`shop.json`, `news.json` 공통). 네이버 뉴스 route(`naver-news/search`)는 이 키가 **필수**이며 없으면 `503 upstream_not_configured` 를 돌려준다. 네이버 쇼핑 route(`naver-shopping/search`)는 **선택**이며 설정되면 공식 API 를 우선 사용하고, 없으면 공개 BFF JSON 파서로 fallback 한다. 공식 쇼핑 API 는 `review` 정렬을 지원하지 않아 `meta.sort_applied: "unsupported"`로 표시한다. no-key 쇼핑 fallback 은 `page`를 BFF에 전달해 해당 페이지를 고르고, `price_asc`/`price_dsc`/`review`는 선택 페이지 안에서 로컬 정렬하며, `date`는 `meta.sort_applied: "unsupported"`로 표시
|
||||||
- `KSKILL_PROXY_HOST` — 기본 `127.0.0.1`
|
- `KSKILL_PROXY_HOST` — 기본 `127.0.0.1`
|
||||||
- `KSKILL_PROXY_PORT` — 기본 `4020`
|
- `KSKILL_PROXY_PORT` — 기본 `4020`
|
||||||
- `KSKILL_PROXY_CACHE_TTL_MS` — 기본 `300000`
|
- `KSKILL_PROXY_CACHE_TTL_MS` — 기본 `300000`
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "node --check src/airkorea.js && node --check src/bluer.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/lh-notice.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-shopping.js && node --check src/parking-lots.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/lh-notice.test.js && node --check test/molit.test.js && node --check test/naver-shopping.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
|
"lint": "node --check src/airkorea.js && node --check src/bluer.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/lh-notice.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-news.js && node --check src/naver-shopping.js && node --check src/parking-lots.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/lh-notice.test.js && node --check test/molit.test.js && node --check test/naver-news.test.js && node --check test/naver-shopping.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
|
||||||
"test": "node --test"
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
233
packages/k-skill-proxy/src/naver-news.js
Normal file
233
packages/k-skill-proxy/src/naver-news.js
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
const NAVER_NEWS_OPEN_API_URL = "https://openapi.naver.com/v1/search/news.json";
|
||||||
|
const DEFAULT_DISPLAY = 10;
|
||||||
|
const MIN_DISPLAY = 1;
|
||||||
|
const MAX_DISPLAY = 100;
|
||||||
|
const DEFAULT_START = 1;
|
||||||
|
const MIN_START = 1;
|
||||||
|
const MAX_START = 1000;
|
||||||
|
const ALLOWED_SORTS = new Set(["sim", "date"]);
|
||||||
|
|
||||||
|
function parseInteger(value, fallback) {
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimOrNull(value) {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = String(value).trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHtmlEntities(value) {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value)
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/&#(\d+);/g, (_match, code) => String.fromCodePoint(Number.parseInt(code, 10)))
|
||||||
|
.replace(/&#x([0-9a-f]+);/gi, (_match, code) => String.fromCodePoint(Number.parseInt(code, 16)))
|
||||||
|
.replace(/&/g, "&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripTags(value) {
|
||||||
|
return decodeHtmlEntities(value)
|
||||||
|
.replace(/<\/?[^>]+(>|$)/g, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeString(value) {
|
||||||
|
const normalized = stripTags(value);
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(value) {
|
||||||
|
const raw = trimOrNull(value);
|
||||||
|
if (!raw || /^javascript:/i.test(raw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (/^https?:\/\//i.test(raw)) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePubDateIso(rfc822) {
|
||||||
|
if (!rfc822) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const date = new Date(rfc822);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNaverNewsSearchQuery(query) {
|
||||||
|
const q = trimOrNull(query?.q ?? query?.query ?? query?.keyword);
|
||||||
|
if (!q) {
|
||||||
|
throw new Error("Provide q/query.");
|
||||||
|
}
|
||||||
|
if ([...q].length < 2) {
|
||||||
|
throw new Error("q/query must be at least 2 characters.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawDisplay = parseInteger(query.display ?? query.limit ?? query.size, DEFAULT_DISPLAY);
|
||||||
|
const rawStart = parseInteger(query.start ?? query.offset, DEFAULT_START);
|
||||||
|
const requestedSort = trimOrNull(query.sort) || "sim";
|
||||||
|
const sort = ALLOWED_SORTS.has(requestedSort) ? requestedSort : "sim";
|
||||||
|
|
||||||
|
return {
|
||||||
|
query: q,
|
||||||
|
display: clamp(rawDisplay, MIN_DISPLAY, MAX_DISPLAY),
|
||||||
|
start: clamp(rawStart, MIN_START, MAX_START),
|
||||||
|
sort
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNaverNewsSearchUrl({ query, display = DEFAULT_DISPLAY, start = DEFAULT_START, sort = "sim" } = {}) {
|
||||||
|
const url = new URL(NAVER_NEWS_OPEN_API_URL);
|
||||||
|
url.searchParams.set("query", query);
|
||||||
|
url.searchParams.set("display", String(display));
|
||||||
|
url.searchParams.set("start", String(start));
|
||||||
|
url.searchParams.set("sort", sort);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNaverNewsSearchPayload(
|
||||||
|
payload,
|
||||||
|
{ query = null, display = DEFAULT_DISPLAY, start = DEFAULT_START, sort = "sim" } = {}
|
||||||
|
) {
|
||||||
|
const items = Array.isArray(payload?.items) ? payload.items : [];
|
||||||
|
const normalized = [];
|
||||||
|
const seenLinks = new Set();
|
||||||
|
const normalizedSort = ALLOWED_SORTS.has(sort) ? sort : "sim";
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const title = normalizeString(item.title);
|
||||||
|
const link = normalizeUrl(item.link);
|
||||||
|
if (!title || !link) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalLink = normalizeUrl(item.originallink);
|
||||||
|
const description = normalizeString(item.description);
|
||||||
|
const pubDate = trimOrNull(item.pubDate);
|
||||||
|
const pubDateIso = parsePubDateIso(pubDate);
|
||||||
|
|
||||||
|
const dedupKey = link.toLowerCase();
|
||||||
|
if (seenLinks.has(dedupKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenLinks.add(dedupKey);
|
||||||
|
|
||||||
|
normalized.push({
|
||||||
|
rank: normalized.length + 1,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
link,
|
||||||
|
original_link: originalLink,
|
||||||
|
pub_date: pubDate,
|
||||||
|
pub_date_iso: pubDateIso,
|
||||||
|
source: "naver-openapi"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: normalized,
|
||||||
|
meta: {
|
||||||
|
query,
|
||||||
|
extraction: "naver-openapi",
|
||||||
|
item_count: normalized.length,
|
||||||
|
total: parseInteger(payload?.total, 0),
|
||||||
|
start: parseInteger(payload?.start, start),
|
||||||
|
display: parseInteger(payload?.display, display),
|
||||||
|
last_build_date: normalizeString(payload?.lastBuildDate),
|
||||||
|
sort: normalizedSort
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchNaverNewsSearch({
|
||||||
|
query,
|
||||||
|
display = DEFAULT_DISPLAY,
|
||||||
|
start = DEFAULT_START,
|
||||||
|
sort = "sim",
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
fetchImpl = global.fetch
|
||||||
|
} = {}) {
|
||||||
|
if (typeof fetchImpl !== "function") {
|
||||||
|
throw new Error("fetch is not available in this Node runtime.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
const error = new Error(
|
||||||
|
"NAVER_SEARCH_CLIENT_ID and NAVER_SEARCH_CLIENT_SECRET are not configured on the proxy server."
|
||||||
|
);
|
||||||
|
error.code = "upstream_not_configured";
|
||||||
|
error.statusCode = 503;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = buildNaverNewsSearchUrl({ query, display, start, sort });
|
||||||
|
const response = await fetchImpl(url, {
|
||||||
|
headers: {
|
||||||
|
"X-Naver-Client-Id": clientId,
|
||||||
|
"X-Naver-Client-Secret": clientSecret,
|
||||||
|
accept: "application/json"
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(15000)
|
||||||
|
});
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = new Error(`Naver News Search API responded with ${response.status}.`);
|
||||||
|
error.code = "upstream_error";
|
||||||
|
error.statusCode = response.status >= 400 && response.status < 500 ? response.status : 502;
|
||||||
|
error.upstreamStatusCode = response.status;
|
||||||
|
error.upstreamBodySnippet = body.slice(0, 200);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(body);
|
||||||
|
} catch (cause) {
|
||||||
|
const error = new Error("Naver News Search API returned invalid JSON.");
|
||||||
|
error.code = "invalid_upstream_response";
|
||||||
|
error.statusCode = 502;
|
||||||
|
error.cause = cause;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = normalizeNaverNewsSearchPayload(payload, { query, display, start, sort });
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
upstream: {
|
||||||
|
url: url.toString(),
|
||||||
|
status_code: response.status,
|
||||||
|
content_type: response.headers.get("content-type") || null,
|
||||||
|
provider: "naver-search-api"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildNaverNewsSearchUrl,
|
||||||
|
fetchNaverNewsSearch,
|
||||||
|
normalizeNaverNewsSearchPayload,
|
||||||
|
normalizeNaverNewsSearchQuery
|
||||||
|
};
|
||||||
|
|
@ -20,6 +20,7 @@ const {
|
||||||
normalizeLhNoticeSearchQuery
|
normalizeLhNoticeSearchQuery
|
||||||
} = require("./lh-notice");
|
} = require("./lh-notice");
|
||||||
const { fetchTransactions, VALID_ASSET_TYPES, VALID_DEAL_TYPES } = require("./molit");
|
const { fetchTransactions, VALID_ASSET_TYPES, VALID_DEAL_TYPES } = require("./molit");
|
||||||
|
const { fetchNaverNewsSearch, normalizeNaverNewsSearchQuery } = require("./naver-news");
|
||||||
const { fetchNaverShoppingSearch, normalizeNaverShoppingSearchQuery } = require("./naver-shopping");
|
const { fetchNaverShoppingSearch, normalizeNaverShoppingSearchQuery } = require("./naver-shopping");
|
||||||
const { fetchNearbyParkingLots } = require("./parking-lots");
|
const { fetchNearbyParkingLots } = require("./parking-lots");
|
||||||
const { searchRegionCode } = require("./region-lookup");
|
const { searchRegionCode } = require("./region-lookup");
|
||||||
|
|
@ -1293,7 +1294,8 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
||||||
neisSchoolMealConfigured: Boolean(config.keduInfoKey),
|
neisSchoolMealConfigured: Boolean(config.keduInfoKey),
|
||||||
krxConfigured: Boolean(config.krxApiKey),
|
krxConfigured: Boolean(config.krxApiKey),
|
||||||
naverShoppingConfigured: true,
|
naverShoppingConfigured: true,
|
||||||
naverSearchApiConfigured: Boolean(config.naverSearchClientId && config.naverSearchClientSecret)
|
naverSearchApiConfigured: Boolean(config.naverSearchClientId && config.naverSearchClientSecret),
|
||||||
|
naverNewsApiConfigured: Boolean(config.naverSearchClientId && config.naverSearchClientSecret)
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
tokenRequired: false
|
tokenRequired: false
|
||||||
|
|
@ -2760,6 +2762,94 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
||||||
return payload;
|
return payload;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
app.get("/v1/naver-news/search", async (request, reply) => {
|
||||||
|
let normalized;
|
||||||
|
|
||||||
|
try {
|
||||||
|
normalized = normalizeNaverNewsSearchQuery(request.query || {});
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400);
|
||||||
|
return {
|
||||||
|
error: "bad_request",
|
||||||
|
message: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = makeCacheKey({
|
||||||
|
route: "naver-news-search",
|
||||||
|
q: normalized.query.toLowerCase(),
|
||||||
|
display: normalized.display,
|
||||||
|
start: normalized.start,
|
||||||
|
sort: normalized.sort
|
||||||
|
});
|
||||||
|
const cached = cache.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return {
|
||||||
|
...cached,
|
||||||
|
proxy: {
|
||||||
|
...cached.proxy,
|
||||||
|
cache: {
|
||||||
|
hit: true,
|
||||||
|
ttl_ms: config.cacheTtlMs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await fetchNaverNewsSearch({
|
||||||
|
...normalized,
|
||||||
|
clientId: config.naverSearchClientId,
|
||||||
|
clientSecret: config.naverSearchClientSecret
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
|
||||||
|
const payload = {
|
||||||
|
error: error.code || "proxy_error",
|
||||||
|
message: error.message,
|
||||||
|
proxy: {
|
||||||
|
name: config.proxyName,
|
||||||
|
cache: {
|
||||||
|
hit: false,
|
||||||
|
ttl_ms: config.cacheTtlMs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (error.upstreamStatusCode) {
|
||||||
|
payload.upstream = {
|
||||||
|
status_code: error.upstreamStatusCode,
|
||||||
|
body_snippet: error.upstreamBodySnippet || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
items: result.items,
|
||||||
|
query: {
|
||||||
|
q: normalized.query,
|
||||||
|
display: normalized.display,
|
||||||
|
start: normalized.start,
|
||||||
|
sort: normalized.sort
|
||||||
|
},
|
||||||
|
meta: result.meta,
|
||||||
|
upstream: result.upstream,
|
||||||
|
proxy: {
|
||||||
|
name: config.proxyName,
|
||||||
|
cache: {
|
||||||
|
hit: false,
|
||||||
|
ttl_ms: config.cacheTtlMs
|
||||||
|
},
|
||||||
|
requested_at: new Date().toISOString()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
cache.set(cacheKey, payload, config.cacheTtlMs);
|
||||||
|
return payload;
|
||||||
|
});
|
||||||
|
|
||||||
async function handleData4LibraryRoute({
|
async function handleData4LibraryRoute({
|
||||||
request,
|
request,
|
||||||
reply,
|
reply,
|
||||||
|
|
|
||||||
508
packages/k-skill-proxy/test/naver-news.test.js
Normal file
508
packages/k-skill-proxy/test/naver-news.test.js
Normal file
|
|
@ -0,0 +1,508 @@
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
|
||||||
|
const {
|
||||||
|
buildNaverNewsSearchUrl,
|
||||||
|
normalizeNaverNewsSearchQuery,
|
||||||
|
normalizeNaverNewsSearchPayload
|
||||||
|
} = require("../src/naver-news");
|
||||||
|
const { buildServer } = require("../src/server");
|
||||||
|
|
||||||
|
test("normalizeNaverNewsSearchQuery validates q/query and clamps display/start/sort", () => {
|
||||||
|
assert.throws(() => normalizeNaverNewsSearchQuery({}), /Provide q\/query/);
|
||||||
|
assert.throws(() => normalizeNaverNewsSearchQuery({ q: "" }), /Provide q\/query/);
|
||||||
|
assert.throws(() => normalizeNaverNewsSearchQuery({ q: " " }), /Provide q\/query/);
|
||||||
|
assert.throws(() => normalizeNaverNewsSearchQuery({ q: "a" }), /at least 2/);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizeNaverNewsSearchQuery({ query: " 인공지능 ", display: "999", start: "9999", sort: "date" }),
|
||||||
|
{
|
||||||
|
query: "인공지능",
|
||||||
|
display: 100,
|
||||||
|
start: 1000,
|
||||||
|
sort: "date"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizeNaverNewsSearchQuery({ q: "삼성전자" }),
|
||||||
|
{
|
||||||
|
query: "삼성전자",
|
||||||
|
display: 10,
|
||||||
|
start: 1,
|
||||||
|
sort: "sim"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizeNaverNewsSearchQuery({ q: "정부", display: "0", start: "0", sort: "UNKNOWN" }),
|
||||||
|
{
|
||||||
|
query: "정부",
|
||||||
|
display: 1,
|
||||||
|
start: 1,
|
||||||
|
sort: "sim"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizeNaverNewsSearchQuery({ q: "대한민국", display: "-5", start: "-1" }),
|
||||||
|
{
|
||||||
|
query: "대한민국",
|
||||||
|
display: 1,
|
||||||
|
start: 1,
|
||||||
|
sort: "sim"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeNaverNewsSearchQuery aliases keyword and caps start+display at 1000 window", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizeNaverNewsSearchQuery({ keyword: "스타트업", display: "50", start: "1000" }),
|
||||||
|
{
|
||||||
|
query: "스타트업",
|
||||||
|
display: 50,
|
||||||
|
start: 1000,
|
||||||
|
sort: "sim"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildNaverNewsSearchUrl constructs the official Naver Search news endpoint URL", () => {
|
||||||
|
const url = buildNaverNewsSearchUrl({
|
||||||
|
query: "인공지능",
|
||||||
|
display: 10,
|
||||||
|
start: 1,
|
||||||
|
sort: "sim"
|
||||||
|
});
|
||||||
|
assert.equal(url.hostname, "openapi.naver.com");
|
||||||
|
assert.equal(url.pathname, "/v1/search/news.json");
|
||||||
|
assert.equal(url.searchParams.get("query"), "인공지능");
|
||||||
|
assert.equal(url.searchParams.get("display"), "10");
|
||||||
|
assert.equal(url.searchParams.get("start"), "1");
|
||||||
|
assert.equal(url.searchParams.get("sort"), "sim");
|
||||||
|
|
||||||
|
const dateUrl = buildNaverNewsSearchUrl({
|
||||||
|
query: "삼성전자",
|
||||||
|
display: 30,
|
||||||
|
start: 20,
|
||||||
|
sort: "date"
|
||||||
|
});
|
||||||
|
assert.equal(dateUrl.searchParams.get("sort"), "date");
|
||||||
|
assert.equal(dateUrl.searchParams.get("display"), "30");
|
||||||
|
assert.equal(dateUrl.searchParams.get("start"), "20");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeNaverNewsSearchPayload maps Naver API items, strips <b> tags and decodes entities", () => {
|
||||||
|
const result = normalizeNaverNewsSearchPayload(
|
||||||
|
{
|
||||||
|
lastBuildDate: "Mon, 26 Sep 2016 11:01:35 +0900",
|
||||||
|
total: 2566589,
|
||||||
|
start: 1,
|
||||||
|
display: 2,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "국내 <b>주식</b>형펀드서 사흘째 자금 순유출",
|
||||||
|
originallink: "http://app.yonhapnews.co.kr/YNA/Basic/SNS/r.aspx?c=AKR20160926019000008",
|
||||||
|
link: "http://openapi.naver.com/l?AAAC2NSw",
|
||||||
|
description: "국내 <b>주식</b>형 펀드에서 사흘째 자금이 "빠져나갔다". 26일 금융투자협회에 따르면 지난 22일 상장지수펀드(ETF)를 제외한 국내 <b>주식</b>형 펀드에서 126억원이 순유출...",
|
||||||
|
pubDate: "Mon, 26 Sep 2016 07:50:00 +0900"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "두 번째 & <b>기사</b> 제목",
|
||||||
|
originallink: "",
|
||||||
|
link: "https://news.naver.com/main/read.nhn?oid=001&aid=000",
|
||||||
|
description: "두 번째 기사 본문 요약...",
|
||||||
|
pubDate: "Mon, 26 Sep 2016 07:00:00 +0900"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ query: "주식", display: 10, start: 1, sort: "sim" }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.items.length, 2);
|
||||||
|
|
||||||
|
const first = result.items[0];
|
||||||
|
assert.equal(first.rank, 1);
|
||||||
|
assert.equal(first.title, "국내 주식형펀드서 사흘째 자금 순유출");
|
||||||
|
assert.equal(first.original_link, "http://app.yonhapnews.co.kr/YNA/Basic/SNS/r.aspx?c=AKR20160926019000008");
|
||||||
|
assert.equal(first.link, "http://openapi.naver.com/l?AAAC2NSw");
|
||||||
|
assert.match(first.description, /국내 주식형 펀드에서 사흘째 자금이 "빠져나갔다"/);
|
||||||
|
assert.doesNotMatch(first.description, /<b>/);
|
||||||
|
assert.equal(first.pub_date, "Mon, 26 Sep 2016 07:50:00 +0900");
|
||||||
|
assert.equal(first.pub_date_iso, "2016-09-25T22:50:00.000Z");
|
||||||
|
assert.equal(first.source, "naver-openapi");
|
||||||
|
|
||||||
|
const second = result.items[1];
|
||||||
|
assert.equal(second.rank, 2);
|
||||||
|
assert.equal(second.title, "두 번째 & 기사 제목");
|
||||||
|
assert.equal(second.original_link, null);
|
||||||
|
assert.equal(second.link, "https://news.naver.com/main/read.nhn?oid=001&aid=000");
|
||||||
|
|
||||||
|
assert.equal(result.meta.query, "주식");
|
||||||
|
assert.equal(result.meta.extraction, "naver-openapi");
|
||||||
|
assert.equal(result.meta.item_count, 2);
|
||||||
|
assert.equal(result.meta.total, 2566589);
|
||||||
|
assert.equal(result.meta.start, 1);
|
||||||
|
assert.equal(result.meta.display, 2);
|
||||||
|
assert.equal(result.meta.last_build_date, "Mon, 26 Sep 2016 11:01:35 +0900");
|
||||||
|
assert.equal(result.meta.sort, "sim");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeNaverNewsSearchPayload skips items without title or link and deduplicates by link", () => {
|
||||||
|
const result = normalizeNaverNewsSearchPayload(
|
||||||
|
{
|
||||||
|
lastBuildDate: "Mon, 22 Apr 2026 00:00:00 +0900",
|
||||||
|
total: 3,
|
||||||
|
start: 1,
|
||||||
|
display: 3,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "정상 기사",
|
||||||
|
originallink: "https://news.example.com/1",
|
||||||
|
link: "https://n.news.naver.com/mnews/article/1",
|
||||||
|
description: "본문",
|
||||||
|
pubDate: "Mon, 22 Apr 2026 00:00:00 +0900"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "",
|
||||||
|
originallink: "https://news.example.com/2",
|
||||||
|
link: "https://n.news.naver.com/mnews/article/2",
|
||||||
|
description: "본문",
|
||||||
|
pubDate: "Mon, 22 Apr 2026 00:00:00 +0900"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "중복 link 기사",
|
||||||
|
originallink: "https://news.example.com/dupe",
|
||||||
|
link: "https://n.news.naver.com/mnews/article/1",
|
||||||
|
description: "본문",
|
||||||
|
pubDate: "Mon, 22 Apr 2026 00:00:00 +0900"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ query: "테스트", display: 10, start: 1, sort: "sim" }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.items.length, 1);
|
||||||
|
assert.equal(result.items[0].title, "정상 기사");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeNaverNewsSearchPayload handles missing optional fields gracefully", () => {
|
||||||
|
const result = normalizeNaverNewsSearchPayload(
|
||||||
|
{
|
||||||
|
lastBuildDate: "Mon, 22 Apr 2026 00:00:00 +0900",
|
||||||
|
total: 1,
|
||||||
|
start: 1,
|
||||||
|
display: 1,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "간단 기사",
|
||||||
|
originallink: "https://news.example.com/1",
|
||||||
|
link: "https://news.example.com/1",
|
||||||
|
description: "",
|
||||||
|
pubDate: "invalid-date-string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ query: "테스트", display: 10, start: 1, sort: "sim" }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.items.length, 1);
|
||||||
|
assert.equal(result.items[0].description, null);
|
||||||
|
assert.equal(result.items[0].pub_date, "invalid-date-string");
|
||||||
|
assert.equal(result.items[0].pub_date_iso, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("naver news search endpoint returns 400 when query is missing", async (t) => {
|
||||||
|
const app = buildServer({
|
||||||
|
env: {
|
||||||
|
NAVER_SEARCH_CLIENT_ID: "client-id",
|
||||||
|
NAVER_SEARCH_CLIENT_SECRET: "client-secret",
|
||||||
|
KSKILL_PROXY_CACHE_TTL_MS: "60000"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
t.after(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "GET", url: "/v1/naver-news/search" });
|
||||||
|
assert.equal(response.statusCode, 400);
|
||||||
|
assert.equal(response.json().error, "bad_request");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("naver news search endpoint returns 503 when proxy credentials are missing", async (t) => {
|
||||||
|
const app = buildServer({ env: { KSKILL_PROXY_CACHE_TTL_MS: "60000" } });
|
||||||
|
|
||||||
|
t.after(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/v1/naver-news/search?q=%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90"
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 503);
|
||||||
|
const body = response.json();
|
||||||
|
assert.equal(body.error, "upstream_not_configured");
|
||||||
|
assert.match(body.message, /NAVER_SEARCH_CLIENT_ID/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("naver news search endpoint proxies to official API with correct headers and params", async (t) => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
const fetchCalls = [];
|
||||||
|
global.fetch = async (url, options = {}) => {
|
||||||
|
fetchCalls.push({ url: String(url), headers: options.headers });
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
lastBuildDate: "Mon, 22 Apr 2026 10:00:00 +0900",
|
||||||
|
total: 1234567,
|
||||||
|
start: 1,
|
||||||
|
display: 2,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "<b>삼성전자</b> 1분기 실적 발표",
|
||||||
|
originallink: "https://news.example.com/samsung",
|
||||||
|
link: "https://n.news.naver.com/mnews/article/samsung",
|
||||||
|
description: "<b>삼성전자</b>가 올해 1분기 실적을 발표했다.",
|
||||||
|
pubDate: "Mon, 22 Apr 2026 09:30:00 +0900"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "두 번째 기사",
|
||||||
|
originallink: "https://news.example.com/second",
|
||||||
|
link: "https://n.news.naver.com/mnews/article/second",
|
||||||
|
description: "두 번째 기사 요약",
|
||||||
|
pubDate: "Mon, 22 Apr 2026 08:00:00 +0900"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = buildServer({
|
||||||
|
env: {
|
||||||
|
NAVER_SEARCH_CLIENT_ID: "test-client-id",
|
||||||
|
NAVER_SEARCH_CLIENT_SECRET: "test-client-secret",
|
||||||
|
KSKILL_PROXY_CACHE_TTL_MS: "60000"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
t.after(async () => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/v1/naver-news/search?q=%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90&display=5&sort=date"
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
const body = response.json();
|
||||||
|
assert.equal(body.query.q, "삼성전자");
|
||||||
|
assert.equal(body.query.display, 5);
|
||||||
|
assert.equal(body.query.sort, "date");
|
||||||
|
assert.equal(body.items.length, 2);
|
||||||
|
assert.equal(body.items[0].title, "삼성전자 1분기 실적 발표");
|
||||||
|
assert.equal(body.items[0].description, "삼성전자가 올해 1분기 실적을 발표했다.");
|
||||||
|
assert.equal(body.items[0].source, "naver-openapi");
|
||||||
|
assert.equal(body.meta.total, 1234567);
|
||||||
|
assert.equal(body.meta.extraction, "naver-openapi");
|
||||||
|
assert.equal(body.upstream.provider, "naver-search-api");
|
||||||
|
assert.equal(body.proxy.cache.hit, false);
|
||||||
|
|
||||||
|
assert.equal(fetchCalls.length, 1);
|
||||||
|
const upstreamUrl = new URL(fetchCalls[0].url);
|
||||||
|
assert.equal(upstreamUrl.hostname, "openapi.naver.com");
|
||||||
|
assert.equal(upstreamUrl.pathname, "/v1/search/news.json");
|
||||||
|
assert.equal(upstreamUrl.searchParams.get("query"), "삼성전자");
|
||||||
|
assert.equal(upstreamUrl.searchParams.get("display"), "5");
|
||||||
|
assert.equal(upstreamUrl.searchParams.get("sort"), "date");
|
||||||
|
assert.equal(fetchCalls[0].headers["X-Naver-Client-Id"], "test-client-id");
|
||||||
|
assert.equal(fetchCalls[0].headers["X-Naver-Client-Secret"], "test-client-secret");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("naver news search endpoint caches successful responses and serves hit on second call", async (t) => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
let fetchCount = 0;
|
||||||
|
global.fetch = async () => {
|
||||||
|
fetchCount += 1;
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
lastBuildDate: "Mon, 22 Apr 2026 10:00:00 +0900",
|
||||||
|
total: 1,
|
||||||
|
start: 1,
|
||||||
|
display: 1,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "캐시 테스트 기사",
|
||||||
|
originallink: "https://news.example.com/cache",
|
||||||
|
link: "https://n.news.naver.com/mnews/article/cache",
|
||||||
|
description: "본문",
|
||||||
|
pubDate: "Mon, 22 Apr 2026 09:00:00 +0900"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = buildServer({
|
||||||
|
env: {
|
||||||
|
NAVER_SEARCH_CLIENT_ID: "client-id",
|
||||||
|
NAVER_SEARCH_CLIENT_SECRET: "client-secret",
|
||||||
|
KSKILL_PROXY_CACHE_TTL_MS: "60000"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
t.after(async () => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/v1/naver-news/search?q=%EC%BA%90%EC%8B%9C"
|
||||||
|
});
|
||||||
|
assert.equal(firstResponse.statusCode, 200);
|
||||||
|
assert.equal(firstResponse.json().proxy.cache.hit, false);
|
||||||
|
|
||||||
|
const secondResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/v1/naver-news/search?q=%EC%BA%90%EC%8B%9C"
|
||||||
|
});
|
||||||
|
assert.equal(secondResponse.statusCode, 200);
|
||||||
|
assert.equal(secondResponse.json().proxy.cache.hit, true);
|
||||||
|
assert.equal(fetchCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("naver news search endpoint surfaces upstream errors without caching them", async (t) => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
let fetchCount = 0;
|
||||||
|
global.fetch = async () => {
|
||||||
|
fetchCount += 1;
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
errorMessage: "Authentication failed",
|
||||||
|
errorCode: "024"
|
||||||
|
}),
|
||||||
|
{ status: 401, headers: { "content-type": "application/json" } }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = buildServer({
|
||||||
|
env: {
|
||||||
|
NAVER_SEARCH_CLIENT_ID: "bad-id",
|
||||||
|
NAVER_SEARCH_CLIENT_SECRET: "bad-secret",
|
||||||
|
KSKILL_PROXY_CACHE_TTL_MS: "60000"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
t.after(async () => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/v1/naver-news/search?q=%EB%B0%B0%EB%93%A0"
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 401);
|
||||||
|
assert.equal(response.json().error, "upstream_error");
|
||||||
|
assert.equal(response.json().upstream.status_code, 401);
|
||||||
|
|
||||||
|
const retry = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/v1/naver-news/search?q=%EB%B0%B0%EB%93%A0"
|
||||||
|
});
|
||||||
|
assert.equal(retry.statusCode, 401);
|
||||||
|
assert.equal(fetchCount, 2, "failures must not be cached");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("naver news search endpoint surfaces upstream 429 rate-limit and echoes status code", async (t) => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
global.fetch = async () => {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
errorMessage: "Request limit exceeded",
|
||||||
|
errorCode: "010"
|
||||||
|
}),
|
||||||
|
{ status: 429, headers: { "content-type": "application/json" } }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = buildServer({
|
||||||
|
env: {
|
||||||
|
NAVER_SEARCH_CLIENT_ID: "client-id",
|
||||||
|
NAVER_SEARCH_CLIENT_SECRET: "client-secret",
|
||||||
|
KSKILL_PROXY_CACHE_TTL_MS: "60000"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
t.after(async () => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/v1/naver-news/search?q=%EC%A0%9C%ED%95%9C"
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 429);
|
||||||
|
assert.equal(response.json().error, "upstream_error");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("naver news search endpoint reports 5xx upstream failures as 502 proxy error", async (t) => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
global.fetch = async () => {
|
||||||
|
return new Response("Internal Server Error", {
|
||||||
|
status: 500,
|
||||||
|
headers: { "content-type": "text/plain" }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = buildServer({
|
||||||
|
env: {
|
||||||
|
NAVER_SEARCH_CLIENT_ID: "client-id",
|
||||||
|
NAVER_SEARCH_CLIENT_SECRET: "client-secret",
|
||||||
|
KSKILL_PROXY_CACHE_TTL_MS: "60000"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
t.after(async () => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/v1/naver-news/search?q=%EC%9E%A5%EC%95%A0"
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 502);
|
||||||
|
assert.equal(response.json().error, "upstream_error");
|
||||||
|
assert.equal(response.json().upstream.status_code, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("health endpoint exposes naverNewsApiConfigured flag based on credentials", async (t) => {
|
||||||
|
const app = buildServer({
|
||||||
|
env: {
|
||||||
|
NAVER_SEARCH_CLIENT_ID: "client-id",
|
||||||
|
NAVER_SEARCH_CLIENT_SECRET: "client-secret"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
t.after(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "GET", url: "/health" });
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
const body = response.json();
|
||||||
|
assert.equal(body.upstreams.naverNewsApiConfigured, true);
|
||||||
|
|
||||||
|
const appNoKeys = buildServer({ env: {} });
|
||||||
|
const healthNoKeys = await appNoKeys.inject({ method: "GET", url: "/health" });
|
||||||
|
assert.equal(healthNoKeys.json().upstreams.naverNewsApiConfigured, false);
|
||||||
|
await appNoKeys.close();
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue