feat(#267): 카카오맵 스킬 (Kakao Local 장소검색 + Kakao Mobility 자동차 길찾기)

feat(#267): 카카오맵 스킬 (Kakao Local 장소검색 + Kakao Mobility 자동차 길찾기)
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-25 17:18:27 +09:00 committed by GitHub
commit e90897a684
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1425 additions and 4 deletions

View file

@ -0,0 +1,5 @@
---
"k-skill-proxy": minor
---
Add Kakao Map proxy routes (keyword search, category search, coord2address, coord2region, Kakao Mobility car directions) used by the new kakao-map skill (issue #267). All routes inject server-side KAKAO_REST_API_KEY and never forward caller-supplied apiKey query params.

View file

@ -32,6 +32,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 서울 실시간 혼잡도 조회 | `seoul-density` | 서울 주요 121개 핫스팟의 실시간 혼잡도 단계와 추정 인구 조회 | 불필요 | [서울 실시간 혼잡도 가이드](docs/features/seoul-density.md) |
| 서울 따릉이 실시간 대여소 조회 | `seoul-bike` | 현재 좌표 주변 또는 대여소 이름 기준 따릉이 대여 가능 자전거와 빈 거치대 조회 | 불필요 | [서울 따릉이 실시간 대여소 가이드](docs/features/seoul-bike.md) |
| 한국 대중교통 길찾기 | `korean-transit-route` | ODsay LIVE API + Kakao geocoding 기반 출발지→도착지 지하철+버스+도보 경로 및 환승 정보 조회 | 필요 | [한국 대중교통 길찾기 가이드](docs/features/korean-transit-route.md) |
| 카카오맵 장소·자동차 길찾기 | `kakao-map` | Kakao Local 키워드/카테고리/좌표↔주소 변환 + Kakao Mobility 자동차 길찾기(거리·소요시간·통행료·예상 택시요금) | 불필요 | [카카오맵 가이드](docs/features/kakao-map.md) |
| 네이버맵 자동차 길찾기 | `naver-map-route` | `/route`·`/이동루트` 수동 입력 기반 NCP Maps Directions 5 자동차 경로, 지오코딩, 역지오코딩 조회 (mock 기본, live opt-in) | 불필요 | [네이버맵 길찾기 가이드](docs/features/naver-map-route.md) |
| 지하철 분실물 조회 | `subway-lost-property` | 지하철 역/물품명 기준 공식 LOST112 분실물 검색 조건과 유실물센터 진입점 안내 | 불필요 | [지하철 분실물 조회 가이드](docs/features/subway-lost-property.md) |
| 긱뉴스 조회 | `geeknews-search` | GeekNews 공개 RSS/Atom 피드 기반 최신 글 목록, 검색, 상세 확인 | 불필요 | [긱뉴스 조회 가이드](docs/features/geeknews-search.md) |
@ -152,6 +153,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [서울 지하철 도착정보 조회](docs/features/seoul-subway-arrival.md)
- [서울 실시간 혼잡도 조회](docs/features/seoul-density.md)
- [한국 대중교통 길찾기 가이드](docs/features/korean-transit-route.md)
- [카카오맵 가이드](docs/features/kakao-map.md)
- [네이버맵 길찾기 가이드](docs/features/naver-map-route.md)
- [지하철 분실물 조회 가이드](docs/features/subway-lost-property.md)
- [긱뉴스 조회 가이드](docs/features/geeknews-search.md)

104
docs/features/kakao-map.md Normal file
View file

@ -0,0 +1,104 @@
# 카카오맵 가이드
## 이 기능으로 할 수 있는 일
- **장소 검색**: 키워드(`스타벅스`)·카테고리(`FD6`=음식점)·좌표 중심으로 가게·시설 검색 (Kakao Local API)
- **좌표 ↔ 주소 변환**: 좌표 → 도로명/지번 주소, 좌표 → 행정구역(법정동/행정동)
- **자동차 길찾기**: 출발지·목적지 좌표 기준 거리·소요시간·통행료·예상 택시 요금 (Kakao Mobility Directions)
- 모두 `k-skill-proxy` 경유. 사용자 키 발급 불필요.
## 먼저 필요한 것
- [공통 설정 가이드](../setup.md) 확인
- 사용자는 별도 Kakao Developers 앱 생성/키 발급 필요 없음
- 운영자(proxy 서버)는 `KAKAO_REST_API_KEY` 보유
## 기본 경로
기본 hosted path: `https://k-skill-proxy.nomadamas.org/v1/kakao-map/*`, `https://k-skill-proxy.nomadamas.org/v1/kakao-mobility/*`
`KSKILL_PROXY_BASE_URL` 환경변수로 override 가능.
## Proxy routes
| endpoint | upstream | 주요 입력 |
|---|---|---|
| `GET /v1/kakao-map/search/keyword` | `https://dapi.kakao.com/v2/local/search/keyword.json` | `q`, `x`, `y`, `radius`, `category_group_code`, `sort`, `page`, `size` |
| `GET /v1/kakao-map/search/category` | `https://dapi.kakao.com/v2/local/search/category.json` | `category_group_code`, `x`, `y`, `radius`, `sort`, `page`, `size` |
| `GET /v1/kakao-map/coord2address` | `https://dapi.kakao.com/v2/local/geo/coord2address.json` | `x`, `y`, `input_coord` |
| `GET /v1/kakao-map/coord2region` | `https://dapi.kakao.com/v2/local/geo/coord2regioncode.json` | `x`, `y`, `input_coord` |
| `GET /v1/kakao-mobility/directions` | `https://apis-navi.kakaomobility.com/v1/directions` | `origin=x,y`, `destination=x,y`, `waypoints`, `priority`(RECOMMEND\|TIME\|DISTANCE), `car_fuel`, `car_hipass`, `alternatives`, `avoid`(ferries\|toll\|motorway\|schoolzone\|uturn; `\|` 구분) |
## 기본 흐름
1. 사용자가 장소 키워드/카테고리/좌표/길찾기 질문을 한다.
2. 적합한 endpoint를 골라 proxy 로 호출한다 (위 표 참고).
3. proxy는 `KAKAO_REST_API_KEY` 를 서버측에서만 `Authorization: KakaoAK ...` 헤더로 주입한다.
4. 응답에서 핵심 필드만 추려 사용자에게 정리해 전달한다.
5. 성공 응답은 proxy cache(기본 TTL 5분)로 보관해 다음 동일 쿼리를 빠르게 돌려준다.
## 예시
키워드 검색:
```bash
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
curl -fsS --get "${BASE}/v1/kakao-map/search/keyword" \
--data-urlencode 'q=스타벅스' \
--data-urlencode 'x=127.0276' \
--data-urlencode 'y=37.4979' \
--data-urlencode 'radius=500' \
--data-urlencode 'sort=distance'
```
좌표 → 주소:
```bash
curl -fsS --get "${BASE}/v1/kakao-map/coord2address" \
--data-urlencode 'x=127.0276' \
--data-urlencode 'y=37.4979'
```
자동차 길찾기:
```bash
curl -fsS --get "${BASE}/v1/kakao-mobility/directions" \
--data-urlencode 'origin=126.9706,37.5559' \
--data-urlencode 'destination=127.0276,37.4979' \
--data-urlencode 'priority=RECOMMEND' \
--data-urlencode 'avoid=toll'
```
응답 요약(예):
```text
자동차 경로: (126.9706,37.5559) → (127.0276,37.4979)
- 거리: 12.3km / 예상 소요시간: 25분
- 통행료: 1,200원 / 예상 택시요금: 18,500원
- 옵션: RECOMMEND, avoid=toll
```
## fallback / 대체 흐름
- 키 누락(`503 upstream_not_configured`) → 사용자에게 운영자 설정 필요 안내
- 인증 실패(401/403) → `503` 으로 변환 (key revoke / 쿼터 초과)
- 좌표 형식 오류 / 미존재 카테고리 코드 → `400 bad_request`
- 경로 미발견·출발지=도착지 근접 등 semantic 실패 → `502 upstream_semantic_error` + `result_msg`
- 네트워크 실패 → `502 upstream_error`
## 주의할 점
- Kakao Mobility는 **자동차 전용**이다. 대중교통 길찾기는 [한국 대중교통 길찾기 가이드](korean-transit-route.md) 를 쓴다.
- 카테고리 검색은 좌표 중심(`x`, `y`)이 필수다.
- waypoints 는 최대 5개 (Kakao Mobility 정책).
- 통행료 회피는 `avoid=toll`을 사용한다. `priority=DISTANCE`는 최단거리 우선순위일 뿐 통행료 회피와 동의어가 아니다.
- Kakao Mobility 무료 일일 쿼터는 1,000건 수준이다. proxy cache + rate-limit이 보호 역할을 하지만, 대량 호출은 자제한다.
- 본 스킬은 데이터 조회 전용이다. 예약·결제·자동 운전은 하지 않는다.
- secret/token/.env 원문은 응답에 노출되지 않는다 (proxy가 키를 서버측에서만 주입).
## 참고 표면
- Kakao Developers Console: `https://developers.kakao.com`
- Kakao Local API 문서: `https://developers.kakao.com/docs/latest/ko/local/dev-guide`
- Kakao Mobility 안내: `https://developers.kakao.com/docs/latest/ko/kakaonavi/common`
- proxy 운영 안내: [k-skill 프록시 서버 가이드](k-skill-proxy.md)

View file

@ -8,7 +8,8 @@
- `korail2` anti-bot bypass PR #54: https://github.com/carpedm20/korail2/pull/54
- 국가데이터처(구 통계청) KOSIS Open API 공식 진입: https://kosis.kr/openapi/ (회원가입·활용신청·개발가이드는 사이트 내부 메뉴 — 직접 deep-link는 SSO/SPA 라우팅으로 빈 화면이 보일 수 있다)
- KOSIS Open API endpoint host: https://kosis.kr/openapi/ — 일반 helper 호출은 `k-skill-proxy``/v1/kosis/search`, `/v1/kosis/meta`, `/v1/kosis/data`가 이 host의 `/statisticsSearch.do`, `/statisticsData.do`, `/Param/statisticsParameterData.do` 로 중계한다. `bigdata`/`--direct``/statisticsBigData.do` 등을 직접 호출한다 (HTTPS 전용, 2026-03-05 시행)
- Kakao Local API endpoint host: https://dapi.kakao.com/v2/local/ — `k-skill-proxy``/v1/kakao-local/geocode``/search/address.json` → empty result 시 `/search/keyword.json` 순서로 중계한다.
- Kakao Local API endpoint host: https://dapi.kakao.com/v2/local/ — `k-skill-proxy``/v1/kakao-local/geocode``/search/address.json` → empty result 시 `/search/keyword.json` 순서로 중계한다. 같은 host의 `/search/keyword.json`, `/search/category.json`, `/geo/coord2address.json`, `/geo/coord2regioncode.json``kakao-map` 스킬용 `/v1/kakao-map/*` 라우트가 직접 중계한다.
- Kakao Mobility Directions endpoint: https://apis-navi.kakaomobility.com/v1/directions — `k-skill-proxy``/v1/kakao-mobility/directions`가 운영자 `KAKAO_REST_API_KEY``Authorization: KakaoAK ...` 헤더로 주입해 자동차 길찾기를 중계한다.
- NAVER Cloud Platform Maps Console: https://www.ncloud.com/product/applicationService/maps — `k-skill-proxy``/v1/naver-map/directions`, `/v1/naver-map/geocode`, `/v1/naver-map/reverse-geocode`가 각각 `https://maps.apigw.ntruss.com/map-direction/v1/driving`, `/map-geocode/v2/geocode`, `/map-reversegeocode/v2/gc` 로 중계한다. 운영자 `NAVER_MAP_CLIENT_ID`/`NAVER_MAP_CLIENT_SECRET` 가 필요하다.
- 숲나들e 공식 사이트: https://foresttrip.go.kr/index.jsp
- 숲나들e 로그인: https://www.foresttrip.go.kr/com/login.do

186
kakao-map/SKILL.md Normal file
View file

@ -0,0 +1,186 @@
---
name: kakao-map
description: Kakao Local (장소 검색·주소-좌표 변환) + Kakao Mobility (자동차 길찾기) 를 k-skill-proxy 경유로 조회한다. 사용자 키 불필요.
license: MIT
metadata:
category: transit
locale: ko-KR
phase: v1
---
# Kakao Map
## What this skill does
Kakao Developers의 두 API를 `k-skill-proxy` 경유로 묶어 다음 두 종류 질문에 답한다:
1. **장소 검색** — 키워드/카테고리/좌표 기준으로 가게·시설·랜드마크를 찾고, 좌표↔주소·행정구역을 변환한다 (Kakao Local REST API).
2. **자동차 길찾기** — 출발지·목적지 좌표를 받아 거리·소요시간·통행료·예상 택시 요금을 조회한다 (Kakao Mobility Directions API).
- 운영자 `KAKAO_REST_API_KEY` 를 proxy 서버에만 보관한다. 사용자는 키 발급 필요 없음.
- 두 API 모두 같은 Kakao REST API key (KakaoAK 헤더) 로 인증한다.
- 본 스킬은 **조회 전용**이다. 예약·결제·운전 자동화는 하지 않는다.
## When to use
- "강남역 근처 스타벅스 찾아줘" → keyword 검색 (x,y 중심)
- "역삼동 카페 카테고리로 보여줘" → category 검색 (FD6/CE7 등)
- "이 좌표가 어느 동/도로명 주소야?" → coord2address / coord2region
- "강남역에서 시청까지 자동차로 얼마나 걸려?" → Kakao Mobility directions
- "통행료 회피 경로로 알려줘" → avoid=toll (필요 시 priority=DISTANCE 병행)
## When NOT to use
- 대중교통(지하철·버스) 경로 → Kakao Mobility는 **자동차 전용**. 대중교통은 `korean-transit-route`(ODsay) 사용
- 도보·자전거 경로 (Kakao Mobility에 정식 API 없음)
- 실시간 교통 상황을 1분 단위로 추적 (proxy cache 가 있음)
- 카카오맵 외부 임베드/렌더링 (본 스킬은 데이터 조회만 함)
- 대량 인덱싱/스크래핑 (Kakao 약관 위반 + 일일 quota 초과 위험)
## Prerequisites
- Python 3 표준 라이브러리만 사용 가능. JS/curl 호출도 동일하게 지원.
- optional: `KSKILL_PROXY_BASE_URL` (self-host·별도 프록시 사용 시. 비우면 hosted `https://k-skill-proxy.nomadamas.org` 기본).
## Required environment variables
사용자 머신에는 **필요 없다.** 운영자 proxy 서버에 다음을 둔다:
- `KAKAO_REST_API_KEY` — Kakao Developers REST API 키 (Local + Mobility 공용)
키가 없으면 모든 `/v1/kakao-map/*``/v1/kakao-mobility/*` 라우트가 `503 upstream_not_configured` 를 돌려준다.
## Proxy routes
| endpoint | 용도 | 주요 입력 |
|---|---|---|
| `GET /v1/kakao-map/search/keyword` | 키워드 장소 검색 | `q`, optional `x`,`y`(중심좌표), `radius`(0~20000m), `category_group_code`, `sort`(accuracy\|distance), `page`(1~45), `size`(1~15) |
| `GET /v1/kakao-map/search/category` | 카테고리 장소 검색 (좌표 중심 필수) | `category_group_code`(예: FD6 음식점, CE7 카페), `x`, `y`, `radius`(기본 500), `sort`, `page`, `size` |
| `GET /v1/kakao-map/coord2address` | 좌표 → 도로명/지번 주소 | `x`, `y`, optional `input_coord`(WGS84 기본) |
| `GET /v1/kakao-map/coord2region` | 좌표 → 행정구역(시/도/시군구/동) | `x`, `y`, optional `input_coord` |
| `GET /v1/kakao-mobility/directions` | 자동차 길찾기 | `origin=x,y`, `destination=x,y`, optional `waypoints`(최대 5, `\|` 구분), `priority`(RECOMMEND\|TIME\|DISTANCE), `car_fuel`(GASOLINE\|DIESEL\|LPG), `car_hipass`(true\|false), `alternatives`(true\|false), `avoid`(ferries\|toll\|motorway\|schoolzone\|uturn; `\|` 구분) |
**Kakao 카테고리 그룹 코드** (자주 쓰는 것):
| 코드 | 의미 |
|---|---|
| MT1 | 대형마트 |
| CS2 | 편의점 |
| PK6 | 주차장 |
| OL7 | 주유소/충전소 |
| SW8 | 지하철역 |
| BK9 | 은행 |
| CT1 | 문화시설 |
| AT4 | 관광명소 |
| AD5 | 숙박 |
| FD6 | 음식점 |
| CE7 | 카페 |
| HP8 | 병원 |
| PM9 | 약국 |
## Workflow
### 1. 키워드 검색
```bash
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
curl -fsS --get "${BASE}/v1/kakao-map/search/keyword" \
--data-urlencode 'q=스타벅스' \
--data-urlencode 'x=127.0276' \
--data-urlencode 'y=37.4979' \
--data-urlencode 'radius=500' \
--data-urlencode 'sort=distance'
```
응답의 `documents[]` 에서 `place_name`, `road_address_name`, `phone`, `place_url`, `distance` 를 추출해 사용자에게 보여준다.
### 2. 카테고리 검색
```bash
curl -fsS --get "${BASE}/v1/kakao-map/search/category" \
--data-urlencode 'category_group_code=FD6' \
--data-urlencode 'x=127.0276' \
--data-urlencode 'y=37.4979' \
--data-urlencode 'radius=300'
```
### 3. 좌표 → 주소
```bash
curl -fsS --get "${BASE}/v1/kakao-map/coord2address" \
--data-urlencode 'x=127.0276' \
--data-urlencode 'y=37.4979'
```
`documents[0].road_address.address_name`, `documents[0].address.address_name` 사용.
### 4. 좌표 → 행정구역
```bash
curl -fsS --get "${BASE}/v1/kakao-map/coord2region" \
--data-urlencode 'x=127.0276' \
--data-urlencode 'y=37.4979'
```
응답에 `region_type`(B=법정동, H=행정동) 별 결과가 들어있다.
### 5. 자동차 길찾기
```bash
curl -fsS --get "${BASE}/v1/kakao-mobility/directions" \
--data-urlencode 'origin=126.9706,37.5559' \
--data-urlencode 'destination=127.0276,37.4979' \
--data-urlencode 'priority=RECOMMEND' \
--data-urlencode 'avoid=toll'
```
응답에서 `routes[0].summary` 를 읽는다:
- `distance` (meter) → km 환산
- `duration` (second) → 분 환산
- `fare.taxi`, `fare.toll` → 원
- `priority` (요청한 값 echo)
- `avoid` 요청 시 `toll` 등 회피 옵션 적용
### 6. 출력 포맷
장소 검색:
```text
강남역 근처 스타벅스 5곳 (반경 500m, 가까운 순)
1) 스타벅스 강남R점 — 강남구 테헤란로 ... (120m, 02-...)
2) ...
```
자동차 길찾기:
```text
자동차 경로: (126.9706,37.5559) → (127.0276,37.4979)
- 거리: 12.3km / 예상 소요시간: 25분
- 통행료: 1,200원 / 예상 택시요금: 18,500원
- 옵션: RECOMMEND, avoid=toll
- 조회 시각: 2026-05-23T14:00:00.000Z
```
## Failure modes
- `KAKAO_REST_API_KEY` 미설정 → `503 upstream_not_configured`
- Kakao 인증 실패(401/403) → proxy가 `503` 으로 변환 (key revoke / 쿼터 초과 신호)
- 좌표/파라미터 형식 오류 → `400 bad_request`
- 출발지=도착지가 너무 가까움 (`result_code=104` 등) → `502 upstream_semantic_error` + `result_msg`
- Kakao 일일 쿼터 초과 → `502` 또는 `503` (proxy cache 가 있는 만큼 호출 빈도를 줄임)
- 네트워크 실패 → `502 upstream_error`
## Done when
- 사용자 질문에 맞는 endpoint 1~2개를 선택해 호출했고, 응답을 사람-읽기 좋게 정리했다.
- 좌표나 주소는 출처 endpoint를 함께 명시한다 (Kakao Local vs Kakao Mobility).
- secret/token/.env 원문은 노출되지 않았다.
- 자동차 외 이동 수단을 요청받으면 본 스킬의 범위 외임을 명시하고 `korean-transit-route` 등 대체 안내.
## Notes
- Kakao Mobility는 **자동차 전용** API다. 대중교통 길찾기는 별도 ODsay 기반 `korean-transit-route` 스킬을 쓴다.
- 무료 일일 쿼터(2026년 기준 Local 300,000건 / Mobility 1,000건) 안에서 proxy cache(기본 TTL 5분) + rate-limit(기본 60req/분) 으로 보호한다.
- proxy 운영/환경변수는 [k-skill 프록시 서버 가이드](../docs/features/k-skill-proxy.md) 참고.
- `/v1/kakao-local/geocode` (기존)도 같은 키를 쓰며 여전히 사용 가능하다 (address → keyword 자동 fallback). 본 스킬은 그 위에 keyword/category/coord 계열을 명시적으로 노출한다.

View file

@ -25,6 +25,11 @@
- `GET /v1/korean-stock/base-info`
- `GET /v1/korean-stock/trade-info`
- `GET /v1/kakao-local/geocode` — Kakao Local 주소/장소명 지오코딩(`KAKAO_REST_API_KEY`; caller `apiKey` 무시)
- `GET /v1/kakao-map/search/keyword` — Kakao Local 키워드 장소 검색(좌표 중심·반경·카테고리 필터 지원, `KAKAO_REST_API_KEY`)
- `GET /v1/kakao-map/search/category` — Kakao Local 카테고리 장소 검색(좌표 중심 필수, `KAKAO_REST_API_KEY`)
- `GET /v1/kakao-map/coord2address` — Kakao Local 좌표→도로명/지번 주소(`KAKAO_REST_API_KEY`)
- `GET /v1/kakao-map/coord2region` — Kakao Local 좌표→행정구역(`KAKAO_REST_API_KEY`)
- `GET /v1/kakao-mobility/directions` — Kakao Mobility 자동차 길찾기(`KAKAO_REST_API_KEY`; `avoid=toll|motorway` 등 회피 옵션 지원)
- `GET /v1/naver-map/directions` — NCP Maps Directions 5 자동차 길찾기(`NAVER_MAP_CLIENT_ID`, `NAVER_MAP_CLIENT_SECRET`)
- `GET /v1/naver-map/geocode` — NCP Maps 주소→좌표 지오코딩(`NAVER_MAP_CLIENT_ID`, `NAVER_MAP_CLIENT_SECRET`)
- `GET /v1/naver-map/reverse-geocode` — NCP Maps 좌표→주소 역지오코딩(`NAVER_MAP_CLIENT_ID`, `NAVER_MAP_CLIENT_SECRET`)
@ -64,7 +69,7 @@
- `KEDU_INFO_KEY` — 프록시 서버 쪽 나이스(NEIS) 교육정보 개방 포털 Open API 인증키 (`school-search`, `school-meal`)
- `DATA4LIBRARY_AUTH_KEY` — 프록시 서버 쪽 도서관 정보나루 Open API 인증키 (`data4library/*`)
- `FOODSAFETYKOREA_API_KEY` — 프록시 서버 쪽 식품안전나라 회수정보 live key (`mfds/food-safety/search`; 없으면 sample feed fallback)
- `KAKAO_REST_API_KEY` — 프록시 서버 쪽 Kakao Local REST API 키 (`kakao-local/geocode`)
- `KAKAO_REST_API_KEY` — 프록시 서버 쪽 Kakao REST API 키 (`kakao-local/geocode`, `kakao-map/*`, `kakao-mobility/directions`)
- `KRX_API_KEY` — 프록시 서버 쪽 KRX Open API upstream key
- `KOSIS_API_KEY` 또는 `KSKILL_KOSIS_API_KEY` — 프록시 서버 쪽 KOSIS Open API upstream key (`kosis/search`, `kosis/meta`, `kosis/data`)
- `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"`로 표시

View file

@ -9,7 +9,7 @@
"node": ">=18"
},
"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/kstartup.js && node --check src/lh-notice.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-map.js && node --check src/naver-news.js && node --check src/naver-shopping.js && node --check src/nts-business.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",
"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/kstartup.js && node --check src/kakao-map.js && node --check src/lh-notice.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-map.js && node --check src/naver-news.js && node --check src/naver-shopping.js && node --check src/nts-business.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"
},
"dependencies": {

View file

@ -0,0 +1,515 @@
const KAKAO_LOCAL_API_BASE_URL = "https://dapi.kakao.com/v2/local";
const KAKAO_MOBILITY_API_BASE_URL = "https://apis-navi.kakaomobility.com/v1";
// Kakao Local category group codes (공식)
const KAKAO_CATEGORY_GROUP_CODES = new Set([
"MT1", // 대형마트
"CS2", // 편의점
"PS3", // 어린이집, 유치원
"SC4", // 학교
"AC5", // 학원
"PK6", // 주차장
"OL7", // 주유소, 충전소
"SW8", // 지하철역
"BK9", // 은행
"CT1", // 문화시설
"AG2", // 중개업소
"PO3", // 공공기관
"AT4", // 관광명소
"AD5", // 숙박
"FD6", // 음식점
"CE7", // 카페
"HP8", // 병원
"PM9" // 약국
]);
const KAKAO_MOBILITY_PRIORITY = new Set(["RECOMMEND", "TIME", "DISTANCE"]);
const KAKAO_MOBILITY_CAR_FUEL = new Set(["GASOLINE", "DIESEL", "LPG"]);
const KAKAO_MOBILITY_ROAD_DETAILS = new Set(["true", "false"]);
const KAKAO_MOBILITY_AVOID = new Set(["ferries", "toll", "motorway", "schoolzone", "uturn"]);
function trimOrNull(value) {
if (value === undefined || value === null) {
return null;
}
const trimmed = String(value).trim();
if (!trimmed || trimmed === "replace-me") {
return null;
}
return trimmed;
}
function parseFloatOrNaN(value) {
if (value === undefined || value === null || value === "") {
return Number.NaN;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : Number.NaN;
}
function parseBoundedPositiveInteger(value, { defaultValue, min, max, label }) {
if (value === undefined || value === null || value === "") {
return defaultValue;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || String(parsed) !== String(value).trim()) {
throw new Error(`Provide ${label} as a positive integer.`);
}
if (parsed < min || parsed > max) {
throw new Error(`Provide ${label} between ${min} and ${max}.`);
}
return parsed;
}
function normalizeKakaoKeywordSearchQuery(query) {
const q = trimOrNull(query.q ?? query.query);
if (!q) {
throw new Error("Provide query.");
}
const result = {
query: q,
size: parseBoundedPositiveInteger(query.size ?? query.limit, {
defaultValue: 15,
min: 1,
max: 15,
label: "size"
}),
page: parseBoundedPositiveInteger(query.page, {
defaultValue: 1,
min: 1,
max: 45,
label: "page"
})
};
const xRaw = query.x ?? query.lng ?? query.longitude;
const yRaw = query.y ?? query.lat ?? query.latitude;
const hasX = xRaw !== undefined && xRaw !== null && xRaw !== "";
const hasY = yRaw !== undefined && yRaw !== null && yRaw !== "";
if (hasX !== hasY) {
throw new Error("Provide both x (lng) and y (lat) for coordinate-centered search.");
}
if (hasX && hasY) {
const x = parseFloatOrNaN(xRaw);
const y = parseFloatOrNaN(yRaw);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
throw new Error("Provide x and y as numeric coordinates.");
}
if (x < -180 || x > 180 || y < -90 || y > 90) {
throw new Error("Provide valid x and y coordinates.");
}
result.x = String(x);
result.y = String(y);
}
const radius = query.radius;
if (radius !== undefined && radius !== null && radius !== "") {
if (!result.x || !result.y) {
throw new Error("Provide both x (lng) and y (lat) when using radius.");
}
result.radius = parseBoundedPositiveInteger(radius, {
defaultValue: undefined,
min: 0,
max: 20000,
label: "radius"
});
if (result.radius === undefined) {
delete result.radius;
}
}
const categoryGroupCode = trimOrNull(query.category_group_code ?? query.categoryGroupCode);
if (categoryGroupCode) {
if (!KAKAO_CATEGORY_GROUP_CODES.has(categoryGroupCode)) {
throw new Error(`Provide category_group_code from documented Kakao Local codes.`);
}
result.category_group_code = categoryGroupCode;
}
const sort = trimOrNull(query.sort);
if (sort) {
if (sort !== "distance" && sort !== "accuracy") {
throw new Error("Provide sort as 'distance' or 'accuracy'.");
}
if (sort === "distance" && (!result.x || !result.y)) {
throw new Error("Provide both x (lng) and y (lat) when using sort=distance.");
}
result.sort = sort;
}
return result;
}
function normalizeKakaoCategorySearchQuery(query) {
const categoryGroupCode = trimOrNull(query.category_group_code ?? query.categoryGroupCode);
if (!categoryGroupCode || !KAKAO_CATEGORY_GROUP_CODES.has(categoryGroupCode)) {
throw new Error("Provide category_group_code from documented Kakao Local codes.");
}
const xRaw = query.x ?? query.lng ?? query.longitude;
const yRaw = query.y ?? query.lat ?? query.latitude;
if (xRaw === undefined || yRaw === undefined || xRaw === "" || yRaw === "") {
throw new Error("Provide both x (lng) and y (lat).");
}
const x = parseFloatOrNaN(xRaw);
const y = parseFloatOrNaN(yRaw);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
throw new Error("Provide x and y as numeric coordinates.");
}
if (x < -180 || x > 180 || y < -90 || y > 90) {
throw new Error("Provide valid x and y coordinates.");
}
const result = {
category_group_code: categoryGroupCode,
x: String(x),
y: String(y),
radius: parseBoundedPositiveInteger(query.radius, {
defaultValue: 500,
min: 0,
max: 20000,
label: "radius"
}),
page: parseBoundedPositiveInteger(query.page, {
defaultValue: 1,
min: 1,
max: 45,
label: "page"
}),
size: parseBoundedPositiveInteger(query.size ?? query.limit, {
defaultValue: 15,
min: 1,
max: 15,
label: "size"
})
};
const sort = trimOrNull(query.sort);
if (sort) {
if (sort !== "distance" && sort !== "accuracy") {
throw new Error("Provide sort as 'distance' or 'accuracy'.");
}
result.sort = sort;
}
return result;
}
function normalizeKakaoCoordToAddressQuery(query) {
const xRaw = query.x ?? query.lng ?? query.longitude;
const yRaw = query.y ?? query.lat ?? query.latitude;
if (xRaw === undefined || yRaw === undefined || xRaw === "" || yRaw === "") {
throw new Error("Provide both x (lng) and y (lat).");
}
const x = parseFloatOrNaN(xRaw);
const y = parseFloatOrNaN(yRaw);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
throw new Error("Provide x and y as numeric coordinates.");
}
if (x < -180 || x > 180 || y < -90 || y > 90) {
throw new Error("Provide valid x and y coordinates.");
}
const result = { x: String(x), y: String(y) };
const inputCoord = trimOrNull(query.input_coord ?? query.inputCoord);
if (inputCoord) {
if (!["WGS84", "WCONGNAMUL", "CONGNAMUL", "WTM", "TM"].includes(inputCoord)) {
throw new Error("Provide input_coord as one of WGS84, WCONGNAMUL, CONGNAMUL, WTM, TM.");
}
result.input_coord = inputCoord;
}
return result;
}
function normalizeKakaoMobilityDirectionsQuery(query) {
const originRaw = trimOrNull(query.origin);
const destinationRaw = trimOrNull(query.destination);
if (!originRaw || !destinationRaw) {
throw new Error("Provide origin and destination as 'x,y'.");
}
for (const [label, value] of [["origin", originRaw], ["destination", destinationRaw]]) {
const parts = value.split(",").map((p) => p.trim());
if (parts.length !== 2) {
throw new Error(`Provide ${label} as 'x,y'.`);
}
const x = parseFloatOrNaN(parts[0]);
const y = parseFloatOrNaN(parts[1]);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
throw new Error(`Provide ${label} as numeric 'x,y'.`);
}
if (x < -180 || x > 180 || y < -90 || y > 90) {
throw new Error(`Provide valid ${label} coordinates.`);
}
}
const rawWaypoints = query.waypoints ?? query.waypoint;
let waypoints = null;
if (rawWaypoints !== undefined && rawWaypoints !== null && rawWaypoints !== "") {
const entries = Array.isArray(rawWaypoints) ? rawWaypoints : String(rawWaypoints).split("|");
if (entries.length > 5) {
throw new Error("Provide at most 5 waypoints.");
}
for (const [index, entry] of entries.entries()) {
const parts = entry.split(",").map((p) => p.trim());
if (parts.length !== 2) {
throw new Error(`Provide waypoint[${index}] as numeric 'x,y'.`);
}
const x = parseFloatOrNaN(parts[0]);
const y = parseFloatOrNaN(parts[1]);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
throw new Error(`Provide waypoint[${index}] as numeric 'x,y'.`);
}
if (x < -180 || x > 180 || y < -90 || y > 90) {
throw new Error(`Provide valid waypoint[${index}] coordinates.`);
}
}
waypoints = entries.join("|");
}
const priority = (trimOrNull(query.priority) || "RECOMMEND").toUpperCase();
if (!KAKAO_MOBILITY_PRIORITY.has(priority)) {
throw new Error(`Provide priority as one of ${[...KAKAO_MOBILITY_PRIORITY].join(", ")}.`);
}
const carFuelRaw = trimOrNull(query.car_fuel ?? query.carFuel);
let carFuel = null;
if (carFuelRaw) {
const upper = carFuelRaw.toUpperCase();
if (!KAKAO_MOBILITY_CAR_FUEL.has(upper)) {
throw new Error(`Provide car_fuel as one of ${[...KAKAO_MOBILITY_CAR_FUEL].join(", ")}.`);
}
carFuel = upper;
}
const carHipassRaw = trimOrNull(query.car_hipass ?? query.carHipass);
let carHipass = null;
if (carHipassRaw) {
const lower = carHipassRaw.toLowerCase();
if (!KAKAO_MOBILITY_ROAD_DETAILS.has(lower)) {
throw new Error("Provide car_hipass as 'true' or 'false'.");
}
carHipass = lower === "true";
}
const alternativesRaw = trimOrNull(query.alternatives);
let alternatives = null;
if (alternativesRaw) {
const lower = alternativesRaw.toLowerCase();
if (!KAKAO_MOBILITY_ROAD_DETAILS.has(lower)) {
throw new Error("Provide alternatives as 'true' or 'false'.");
}
alternatives = lower === "true";
}
const avoidRaw = trimOrNull(query.avoid);
let avoid = null;
if (avoidRaw) {
const values = avoidRaw.split("|").map((entry) => entry.trim().toLowerCase()).filter(Boolean);
if (values.length === 0 || values.some((entry) => !KAKAO_MOBILITY_AVOID.has(entry))) {
throw new Error(`Provide avoid as pipe-separated values from ${[...KAKAO_MOBILITY_AVOID].join(", ")}.`);
}
avoid = values.join("|");
}
return {
origin: originRaw,
destination: destinationRaw,
waypoints,
priority,
car_fuel: carFuel,
car_hipass: carHipass,
alternatives,
avoid
};
}
async function fetchKakaoLocalEndpoint({
endpoint,
params = {},
apiKey,
fetchImpl = global.fetch
}) {
const paths = {
keyword: "search/keyword.json",
category: "search/category.json",
address: "search/address.json",
coord2address: "geo/coord2address.json",
coord2region: "geo/coord2regioncode.json"
};
const path = paths[endpoint];
if (!path) {
const error = new Error("That Kakao Local endpoint is not exposed by this proxy.");
error.code = "not_found";
error.statusCode = 404;
throw error;
}
if (!apiKey) {
const error = new Error("KAKAO_REST_API_KEY is not configured on the proxy server.");
error.code = "upstream_not_configured";
error.statusCode = 503;
throw error;
}
const url = new URL(`${KAKAO_LOCAL_API_BASE_URL}/${path}`);
for (const [key, value] of Object.entries(params || {})) {
if (value === undefined || value === null || value === "" || key === "apiKey") {
continue;
}
url.searchParams.set(key, String(value));
}
let response;
try {
response = await fetchImpl(url, {
headers: {
authorization: `KakaoAK ${apiKey}`,
"user-agent": "k-skill-proxy/kakao-map"
},
signal: AbortSignal.timeout(20000)
});
} catch (fetchError) {
const error = new Error("Failed to reach Kakao Local upstream.");
error.code = "upstream_error";
error.statusCode = 502;
error.cause = fetchError;
throw error;
}
const text = await response.text();
const contentType = response.headers.get("content-type") || "application/json; charset=utf-8";
if (response.status < 200 || response.status >= 300) {
const error = new Error("Kakao Local upstream returned an error.");
error.code = "upstream_error";
error.statusCode = response.status === 401 || response.status === 403 ? 503 : 502;
error.upstreamStatusCode = response.status;
error.upstreamBodySnippet = text.slice(0, 200);
throw error;
}
let body;
try {
body = JSON.parse(text);
} catch (parseError) {
const error = new Error("Kakao Local upstream returned non-JSON.");
error.code = "upstream_parse_error";
error.statusCode = 502;
error.cause = parseError;
throw error;
}
return { statusCode: response.status, contentType, body };
}
async function fetchKakaoMobilityDirections({
origin,
destination,
waypoints,
priority,
car_fuel,
car_hipass,
alternatives,
avoid,
apiKey,
fetchImpl = global.fetch
}) {
if (!apiKey) {
const error = new Error("KAKAO_REST_API_KEY is not configured on the proxy server.");
error.code = "upstream_not_configured";
error.statusCode = 503;
throw error;
}
const url = new URL(`${KAKAO_MOBILITY_API_BASE_URL}/directions`);
url.searchParams.set("origin", origin);
url.searchParams.set("destination", destination);
if (waypoints) {
url.searchParams.set("waypoints", waypoints);
}
url.searchParams.set("priority", priority);
if (car_fuel !== null && car_fuel !== undefined) {
url.searchParams.set("car_fuel", car_fuel);
}
if (car_hipass !== null && car_hipass !== undefined) {
url.searchParams.set("car_hipass", String(car_hipass));
}
if (alternatives !== null && alternatives !== undefined) {
url.searchParams.set("alternatives", String(alternatives));
}
if (avoid) {
url.searchParams.set("avoid", avoid);
}
let response;
try {
response = await fetchImpl(url, {
headers: {
authorization: `KakaoAK ${apiKey}`,
accept: "application/json",
"user-agent": "k-skill-proxy/kakao-mobility"
},
signal: AbortSignal.timeout(20000)
});
} catch (fetchError) {
const error = new Error("Failed to reach Kakao Mobility directions upstream.");
error.code = "upstream_error";
error.statusCode = 502;
error.cause = fetchError;
throw error;
}
const text = await response.text();
const contentType = response.headers.get("content-type") || "application/json; charset=utf-8";
if (response.status < 200 || response.status >= 300) {
const error = new Error("Kakao Mobility directions upstream returned an error.");
error.code = "upstream_error";
error.statusCode = response.status === 401 || response.status === 403 ? 503 : 502;
error.upstreamStatusCode = response.status;
error.upstreamBodySnippet = text.slice(0, 200);
throw error;
}
let body;
try {
body = JSON.parse(text);
} catch (parseError) {
const error = new Error("Kakao Mobility directions upstream returned non-JSON.");
error.code = "upstream_parse_error";
error.statusCode = 502;
error.cause = parseError;
throw error;
}
// Kakao Mobility returns routes[].result_code !== 0 for semantic failures.
if (body && Array.isArray(body.routes) && body.routes.length > 0) {
const firstRoute = body.routes[0];
const code = firstRoute && firstRoute.result_code;
if (typeof code === "number" && code !== 0) {
const error = new Error(firstRoute.result_msg || `Kakao Mobility reported result_code ${code}.`);
error.code = "upstream_semantic_error";
error.statusCode = 502;
error.upstreamStatusCode = response.status;
error.upstreamCode = code;
throw error;
}
}
return { statusCode: response.status, contentType, body };
}
module.exports = {
KAKAO_LOCAL_API_BASE_URL,
KAKAO_MOBILITY_API_BASE_URL,
KAKAO_CATEGORY_GROUP_CODES,
KAKAO_MOBILITY_PRIORITY,
KAKAO_MOBILITY_CAR_FUEL,
KAKAO_MOBILITY_AVOID,
fetchKakaoLocalEndpoint,
fetchKakaoMobilityDirections,
normalizeKakaoKeywordSearchQuery,
normalizeKakaoCategorySearchQuery,
normalizeKakaoCoordToAddressQuery,
normalizeKakaoMobilityDirectionsQuery
};

View file

@ -20,6 +20,14 @@ const {
normalizeLhNoticeSearchQuery
} = require("./lh-notice");
const { fetchTransactions, VALID_ASSET_TYPES, VALID_DEAL_TYPES } = require("./molit");
const {
fetchKakaoLocalEndpoint,
fetchKakaoMobilityDirections,
normalizeKakaoCategorySearchQuery,
normalizeKakaoCoordToAddressQuery,
normalizeKakaoKeywordSearchQuery,
normalizeKakaoMobilityDirectionsQuery
} = require("./kakao-map");
const {
fetchNaverMapDirections,
fetchNaverMapGeocode,
@ -1908,6 +1916,8 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
neisSchoolMealConfigured: Boolean(config.keduInfoKey),
krxConfigured: Boolean(config.krxApiKey),
kakaoLocalConfigured: Boolean(config.kakaoRestApiKey),
kakaoMapConfigured: Boolean(config.kakaoRestApiKey),
kakaoMobilityConfigured: Boolean(config.kakaoRestApiKey),
kosisConfigured: Boolean(config.kosisApiKey),
naverShoppingConfigured: true,
naverSearchApiConfigured: naverSearchKeysPresent,
@ -4190,6 +4200,76 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
return payload;
});
async function handleKakaoLocalEndpointRoute({
request,
reply,
route,
endpoint,
normalize
}) {
let normalized;
try {
normalized = normalize(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({ route, ...normalized });
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: { hit: true, ttl_ms: config.cacheTtlMs }
}
};
}
let result;
try {
result = await fetchKakaoLocalEndpoint({
endpoint,
params: normalized,
apiKey: config.kakaoRestApiKey
});
} 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 = {
...result.body,
proxy: {
name: config.proxyName,
cache: { hit: false, ttl_ms: config.cacheTtlMs },
requested_at: new Date().toISOString()
}
};
cache.set(cacheKey, payload, config.cacheTtlMs);
reply.code(result.statusCode);
reply.header("content-type", "application/json; charset=utf-8");
return payload;
}
async function handleNaverMapRoute({
request,
@ -4209,7 +4289,6 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
message: error.message
};
}
const cacheKey = makeCacheKey({ route, ...normalized, ...cacheKeyExtra });
const cached = cache.get(cacheKey);
if (cached) {
@ -4265,6 +4344,102 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
return payload;
}
app.get("/v1/kakao-map/search/keyword", async (request, reply) => handleKakaoLocalEndpointRoute({
request,
reply,
route: "kakao-map-search-keyword",
endpoint: "keyword",
normalize: normalizeKakaoKeywordSearchQuery
}));
app.get("/v1/kakao-map/search/category", async (request, reply) => handleKakaoLocalEndpointRoute({
request,
reply,
route: "kakao-map-search-category",
endpoint: "category",
normalize: normalizeKakaoCategorySearchQuery
}));
app.get("/v1/kakao-map/coord2address", async (request, reply) => handleKakaoLocalEndpointRoute({
request,
reply,
route: "kakao-map-coord2address",
endpoint: "coord2address",
normalize: normalizeKakaoCoordToAddressQuery
}));
app.get("/v1/kakao-map/coord2region", async (request, reply) => handleKakaoLocalEndpointRoute({
request,
reply,
route: "kakao-map-coord2region",
endpoint: "coord2region",
normalize: normalizeKakaoCoordToAddressQuery
}));
app.get("/v1/kakao-mobility/directions", async (request, reply) => {
let normalized;
try {
normalized = normalizeKakaoMobilityDirectionsQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({ route: "kakao-mobility-directions", ...normalized });
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: { hit: true, ttl_ms: config.cacheTtlMs }
}
};
}
let result;
try {
result = await fetchKakaoMobilityDirections({
...normalized,
apiKey: config.kakaoRestApiKey
});
} 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 = {
...result.body,
proxy: {
name: config.proxyName,
cache: { hit: false, ttl_ms: config.cacheTtlMs },
requested_at: new Date().toISOString()
}
};
cache.set(cacheKey, payload, config.cacheTtlMs);
reply.code(result.statusCode);
reply.header("content-type", "application/json; charset=utf-8");
return payload;
});
app.get("/v1/naver-map/directions", async (request, reply) => handleNaverMapRoute({
request,
reply,
@ -4904,6 +5079,10 @@ module.exports = {
normalizeFineDustQuery,
normalizeHanRiverWaterLevelQuery,
normalizeKakaoLocalGeocodeQuery,
normalizeKakaoKeywordSearchQuery,
normalizeKakaoCategorySearchQuery,
normalizeKakaoCoordToAddressQuery,
normalizeKakaoMobilityDirectionsQuery,
normalizeNaverMapDirectionsQuery,
normalizeNaverMapGeocodeQuery,
normalizeNaverMapReverseGeocodeQuery,
@ -4939,6 +5118,8 @@ module.exports = {
proxyKmaWeatherRequest,
proxyKosisRequest,
proxyKstartupRequest,
fetchKakaoLocalEndpoint,
fetchKakaoMobilityDirections,
fetchNaverMapDirections,
fetchNaverMapGeocode,
fetchNaverMapReverseGeocode,

View file

@ -709,6 +709,428 @@ test("Kakao Local geocode endpoint falls back from address to keyword and caches
assert.equal(new URL(calls[0]).searchParams.get("apiKey"), null);
});
test("Kakao Map keyword search injects KakaoAK header, forwards x/y/radius/sort, and caches", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url, options = {}) => {
calls.push({ url: String(url), headers: options.headers });
return new Response(
JSON.stringify({
meta: { total_count: 1, pageable_count: 1, is_end: true },
documents: [
{ place_name: "스타벅스 강남R점", x: "127.027619", y: "37.497946", distance: "120" }
]
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({
env: {
KAKAO_REST_API_KEY: "server-kakao-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const url = "/v1/kakao-map/search/keyword?q=" + encodeURIComponent("스타벅스") + "&x=127.0276&y=37.4979&radius=500&sort=distance&apiKey=client-key";
const first = await app.inject({ method: "GET", url });
assert.equal(first.statusCode, 200);
assert.equal(first.json().documents[0].place_name, "스타벅스 강남R점");
assert.equal(first.json().proxy.cache.hit, false);
const second = await app.inject({ method: "GET", url });
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(calls.length, 1, "second request should be served from proxy cache");
const parsed = new URL(calls[0].url);
assert.equal(parsed.origin + parsed.pathname, "https://dapi.kakao.com/v2/local/search/keyword.json");
assert.equal(parsed.searchParams.get("query"), "스타벅스");
assert.equal(parsed.searchParams.get("x"), "127.0276");
assert.equal(parsed.searchParams.get("y"), "37.4979");
assert.equal(parsed.searchParams.get("radius"), "500");
assert.equal(parsed.searchParams.get("sort"), "distance");
assert.equal(parsed.searchParams.get("apiKey"), null);
assert.equal(calls[0].headers.authorization, "KakaoAK server-kakao-key");
});
test("Kakao Map keyword search validates coordinate pairing and radius bounds", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url, options = {}) => {
calls.push({ url: String(url), headers: options.headers ?? {} });
return jsonResponse({ documents: [], meta: { total_count: 0 } });
};
const app = buildServer({
env: { KAKAO_REST_API_KEY: "server-kakao-key" }
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const missingY = await app.inject({
method: "GET",
url: "/v1/kakao-map/search/keyword?q=hi&x=127.0"
});
assert.equal(missingY.statusCode, 400);
const badSort = await app.inject({
method: "GET",
url: "/v1/kakao-map/search/keyword?q=hi&sort=newest"
});
assert.equal(badSort.statusCode, 400);
const badRadius = await app.inject({
method: "GET",
url: "/v1/kakao-map/search/keyword?q=hi&x=127.0&y=37.5&radius=99999"
});
assert.equal(badRadius.statusCode, 400);
const radiusWithoutCoords = await app.inject({
method: "GET",
url: "/v1/kakao-map/search/keyword?q=hi&radius=500"
});
assert.equal(radiusWithoutCoords.statusCode, 400);
assert.match(radiusWithoutCoords.json().message, /radius/i);
const distanceSortWithoutCoords = await app.inject({
method: "GET",
url: "/v1/kakao-map/search/keyword?q=hi&sort=distance"
});
assert.equal(distanceSortWithoutCoords.statusCode, 400);
assert.match(distanceSortWithoutCoords.json().message, /sort=distance/i);
assert.equal(calls.length, 0, "validation failures should not call Kakao upstream");
});
test("Kakao Map category search rejects unsupported category group codes", async (t) => {
const app = buildServer({
env: { KAKAO_REST_API_KEY: "server-kakao-key" }
});
t.after(async () => {
await app.close();
});
const bad = await app.inject({
method: "GET",
url: "/v1/kakao-map/search/category?category_group_code=XX9&x=127.0&y=37.5"
});
assert.equal(bad.statusCode, 400);
const missing = await app.inject({
method: "GET",
url: "/v1/kakao-map/search/category?category_group_code=FD6"
});
assert.equal(missing.statusCode, 400);
});
test("Kakao Map category search routes to /search/category.json with FD6 and coords", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url, options = {}) => {
calls.push({ url: String(url), headers: options.headers });
return new Response(
JSON.stringify({
meta: { total_count: 0, pageable_count: 0, is_end: true },
documents: []
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({
env: { KAKAO_REST_API_KEY: "k" }
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/kakao-map/search/category?category_group_code=FD6&x=127.0276&y=37.4979&radius=300"
});
assert.equal(response.statusCode, 200);
const parsed = new URL(calls[0].url);
assert.equal(parsed.origin + parsed.pathname, "https://dapi.kakao.com/v2/local/search/category.json");
assert.equal(parsed.searchParams.get("category_group_code"), "FD6");
});
test("Kakao Map coord2region routes to /geo/coord2regioncode.json with input_coord", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url) => {
calls.push(String(url));
return new Response(
JSON.stringify({
meta: { total_count: 1 },
documents: [{ region_type: "B", address_name: "서울특별시 강남구 역삼동" }]
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({
env: { KAKAO_REST_API_KEY: "k" }
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/kakao-map/coord2region?x=127.0276&y=37.4979&input_coord=WGS84"
});
assert.equal(response.statusCode, 200);
assert.match(response.json().documents[0].address_name, /강남구/);
const parsed = new URL(calls[0]);
assert.equal(parsed.origin + parsed.pathname, "https://dapi.kakao.com/v2/local/geo/coord2regioncode.json");
assert.equal(parsed.searchParams.get("input_coord"), "WGS84");
});
test("Kakao Map coord2address routes to /geo/coord2address.json with x/y", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url) => {
calls.push(String(url));
return new Response(
JSON.stringify({
meta: { total_count: 1 },
documents: [
{
road_address: { address_name: "서울 강남구 테헤란로 521" },
address: { address_name: "서울 강남구 역삼동" }
}
]
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({
env: { KAKAO_REST_API_KEY: "k" }
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/kakao-map/coord2address?x=127.0276&y=37.4979"
});
assert.equal(response.statusCode, 200);
assert.match(response.json().documents[0].road_address.address_name, /테헤란로/);
const parsed = new URL(calls[0]);
assert.equal(parsed.origin + parsed.pathname, "https://dapi.kakao.com/v2/local/geo/coord2address.json");
});
test("Kakao Map endpoints return 503 when KAKAO_REST_API_KEY is missing", async (t) => {
const originalFetch = global.fetch;
global.fetch = async () => {
throw new Error("upstream should not be called without API key");
};
const app = buildServer({ env: {} });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const urls = [
"/v1/kakao-map/search/keyword?q=hi",
"/v1/kakao-map/search/category?category_group_code=FD6&x=127&y=37.5",
"/v1/kakao-map/coord2address?x=127&y=37.5",
"/v1/kakao-map/coord2region?x=127&y=37.5",
"/v1/kakao-mobility/directions?origin=127.0,37.5&destination=127.1,37.6"
];
for (const url of urls) {
const response = await app.inject({ method: "GET", url });
assert.equal(response.statusCode, 503, `${url} should report 503 when key is missing`);
assert.equal(response.json().error, "upstream_not_configured");
}
});
test("Kakao Mobility directions endpoint injects KakaoAK, forwards priority/options, and caches", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url, options = {}) => {
calls.push({ url: String(url), headers: options.headers });
return new Response(
JSON.stringify({
trans_id: "abc",
routes: [
{
result_code: 0,
result_msg: "성공",
summary: { distance: 12345, duration: 1200, fare: { taxi: 12000, toll: 1000 } }
}
]
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({ env: { KAKAO_REST_API_KEY: "mob-key" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const url = "/v1/kakao-mobility/directions?origin=126.9706,37.5559&destination=127.0276,37.4979&priority=TIME&car_fuel=GASOLINE&alternatives=true";
const first = await app.inject({ method: "GET", url });
assert.equal(first.statusCode, 200);
assert.equal(first.json().routes[0].result_code, 0);
const second = await app.inject({ method: "GET", url });
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(calls.length, 1);
const parsed = new URL(calls[0].url);
assert.equal(parsed.origin + parsed.pathname, "https://apis-navi.kakaomobility.com/v1/directions");
assert.equal(parsed.searchParams.get("origin"), "126.9706,37.5559");
assert.equal(parsed.searchParams.get("destination"), "127.0276,37.4979");
assert.equal(parsed.searchParams.get("priority"), "TIME");
assert.equal(parsed.searchParams.get("car_fuel"), "GASOLINE");
assert.equal(parsed.searchParams.get("alternatives"), "true");
assert.equal(calls[0].headers.authorization, "KakaoAK mob-key");
});
test("Kakao Mobility directions forwards whitelisted avoid options", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url, options = {}) => {
calls.push({ url: String(url), headers: options.headers });
return new Response(
JSON.stringify({
trans_id: "avoid",
routes: [{ result_code: 0, result_msg: "성공", summary: { distance: 1000, duration: 300 } }]
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({ env: { KAKAO_REST_API_KEY: "mob-key" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/kakao-mobility/directions?origin=126.9706,37.5559&destination=127.0276,37.4979&avoid=toll%7Cmotorway"
});
assert.equal(response.statusCode, 200);
const parsed = new URL(calls[0].url);
assert.equal(parsed.searchParams.get("avoid"), "toll|motorway");
});
test("Kakao Mobility directions endpoint validates coordinate, priority, and waypoint count", async (t) => {
const app = buildServer({ env: { KAKAO_REST_API_KEY: "k" } });
t.after(async () => {
await app.close();
});
const missing = await app.inject({
method: "GET",
url: "/v1/kakao-mobility/directions"
});
assert.equal(missing.statusCode, 400);
const badPriority = await app.inject({
method: "GET",
url: "/v1/kakao-mobility/directions?origin=127.0,37.5&destination=127.1,37.6&priority=CHEAP"
});
assert.equal(badPriority.statusCode, 400);
const badAvoid = await app.inject({
method: "GET",
url: "/v1/kakao-mobility/directions?origin=127.0,37.5&destination=127.1,37.6&avoid=unpaved"
});
assert.equal(badAvoid.statusCode, 400);
const tooManyWaypoints = await app.inject({
method: "GET",
url: "/v1/kakao-mobility/directions?origin=127.0,37.5&destination=127.1,37.6&waypoints="
+ encodeURIComponent("127.0,37.5|127.0,37.5|127.0,37.5|127.0,37.5|127.0,37.5|127.0,37.5")
});
assert.equal(tooManyWaypoints.statusCode, 400);
});
test("Kakao Mobility directions rejects out-of-range waypoints before upstream", async (t) => {
const originalFetch = global.fetch;
let upstreamCalls = 0;
global.fetch = async () => {
upstreamCalls += 1;
throw new Error("Unexpected upstream call for invalid waypoint.");
};
const app = buildServer({ env: { KAKAO_REST_API_KEY: "k" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/kakao-mobility/directions?origin=127.0,37.5&destination=127.1,37.6&waypoints=181,37.55"
});
assert.equal(response.statusCode, 400);
assert.match(response.json().message, /waypoint\[0\]/);
assert.equal(upstreamCalls, 0);
});
test("Kakao Mobility directions surfaces routes[0].result_code != 0 as 502 and does not cache", async (t) => {
const originalFetch = global.fetch;
let upstreamCalls = 0;
global.fetch = async () => {
upstreamCalls += 1;
return new Response(
JSON.stringify({
routes: [{ result_code: 104, result_msg: "출발지와 도착지가 너무 가깝습니다." }]
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({ env: { KAKAO_REST_API_KEY: "k" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const url = "/v1/kakao-mobility/directions?origin=127.0,37.5&destination=127.0001,37.5001";
const first = await app.inject({ method: "GET", url });
assert.equal(first.statusCode, 502);
assert.equal(first.json().error, "upstream_semantic_error");
const second = await app.inject({ method: "GET", url });
assert.equal(second.statusCode, 502);
assert.equal(upstreamCalls, 2, "semantic failures must not be cached");
});
test("Kakao Map health endpoint reflects kakaoMapConfigured and kakaoMobilityConfigured", async (t) => {
const appOff = buildServer({ env: {} });
t.after(async () => {
await appOff.close();
});
const off = await appOff.inject({ method: "GET", url: "/health" });
assert.equal(off.json().upstreams.kakaoMapConfigured, false);
assert.equal(off.json().upstreams.kakaoMobilityConfigured, false);
const appOn = buildServer({ env: { KAKAO_REST_API_KEY: "k" } });
t.after(async () => {
await appOn.close();
});
const on = await appOn.inject({ method: "GET", url: "/health" });
assert.equal(on.json().upstreams.kakaoMapConfigured, true);
assert.equal(on.json().upstreams.kakaoMobilityConfigured, true);
});
test("Naver Map directions endpoint returns 503 when proxy lacks Naver Map keys", async (t) => {
const originalFetch = global.fetch;
global.fetch = async () => {