mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
feat(#267): 카카오맵 스킬 (Kakao Local 장소검색 + Kakao Mobility 자동차 길찾기)
feat(#267): 카카오맵 스킬 (Kakao Local 장소검색 + Kakao Mobility 자동차 길찾기)
This commit is contained in:
commit
e90897a684
10 changed files with 1425 additions and 4 deletions
5
.changeset/issue-267-kakao-map.md
Normal file
5
.changeset/issue-267-kakao-map.md
Normal 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.
|
||||
|
|
@ -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
104
docs/features/kakao-map.md
Normal 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)
|
||||
|
|
@ -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
186
kakao-map/SKILL.md
Normal 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 계열을 명시적으로 노출한다.
|
||||
|
|
@ -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"`로 표시
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
515
packages/k-skill-proxy/src/kakao-map.js
Normal file
515
packages/k-skill-proxy/src/kakao-map.js
Normal 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
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue