mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Keep Kakao route contracts local and explicit
Constraint: PR #283 review requested TDD fixes for Kakao Local distance sorting, Mobility toll avoidance, lint coverage, and coord2region routing coverage. Rejected: Relying on upstream Kakao validation for sort=distance | it spends quota and returns a proxy/upstream error instead of local bad_request. Rejected: Document-only toll avoidance correction | the skill already promises the behavior and Kakao Mobility exposes an explicit avoid option. Confidence: high Scope-risk: narrow Directive: Preserve server-side KAKAO_REST_API_KEY injection only; never accept or forward caller apiKey query values. Tested: node --test packages/k-skill-proxy/test/server.test.js; npm test --workspace k-skill-proxy; npm run lint --workspace k-skill-proxy; node --test scripts/skill-docs.test.js; bash scripts/validate-skills.sh; manual Fastify inject smoke for sort=distance and avoid forwarding; npm run ci through lint/typecheck until local Python pyexpat failure. Not-tested: Full npm run ci completion due local Python 3.14 pyexpat ImportError during pip install.
This commit is contained in:
parent
6d49a28d87
commit
366d346f03
6 changed files with 110 additions and 11 deletions
|
|
@ -27,7 +27,7 @@
|
|||
| `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` |
|
||||
| `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; `\|` 구분) |
|
||||
|
||||
## 기본 흐름
|
||||
|
||||
|
|
@ -65,7 +65,8 @@ curl -fsS --get "${BASE}/v1/kakao-map/coord2address" \
|
|||
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 'priority=RECOMMEND' \
|
||||
--data-urlencode 'avoid=toll'
|
||||
```
|
||||
|
||||
응답 요약(예):
|
||||
|
|
@ -74,7 +75,7 @@ curl -fsS --get "${BASE}/v1/kakao-mobility/directions" \
|
|||
자동차 경로: (126.9706,37.5559) → (127.0276,37.4979)
|
||||
- 거리: 12.3km / 예상 소요시간: 25분
|
||||
- 통행료: 1,200원 / 예상 택시요금: 18,500원
|
||||
- 옵션: RECOMMEND
|
||||
- 옵션: RECOMMEND, avoid=toll
|
||||
```
|
||||
|
||||
## fallback / 대체 흐름
|
||||
|
|
@ -90,6 +91,7 @@ curl -fsS --get "${BASE}/v1/kakao-mobility/directions" \
|
|||
- 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가 키를 서버측에서만 주입).
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ Kakao Developers의 두 API를 `k-skill-proxy` 경유로 묶어 다음 두 종
|
|||
- "역삼동 카페 카테고리로 보여줘" → category 검색 (FD6/CE7 등)
|
||||
- "이 좌표가 어느 동/도로명 주소야?" → coord2address / coord2region
|
||||
- "강남역에서 시청까지 자동차로 얼마나 걸려?" → Kakao Mobility directions
|
||||
- "통행료 회피 경로로 알려줘" → priority=DISTANCE 또는 추가 옵션
|
||||
- "통행료 회피 경로로 알려줘" → avoid=toll (필요 시 priority=DISTANCE 병행)
|
||||
|
||||
## When NOT to use
|
||||
|
||||
|
|
@ -58,7 +58,7 @@ Kakao Developers의 두 API를 `k-skill-proxy` 경유로 묶어 다음 두 종
|
|||
| `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) |
|
||||
| `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 카테고리 그룹 코드** (자주 쓰는 것):
|
||||
|
||||
|
|
@ -130,7 +130,8 @@ curl -fsS --get "${BASE}/v1/kakao-map/coord2region" \
|
|||
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 'priority=RECOMMEND' \
|
||||
--data-urlencode 'avoid=toll'
|
||||
```
|
||||
|
||||
응답에서 `routes[0].summary` 를 읽는다:
|
||||
|
|
@ -139,6 +140,7 @@ curl -fsS --get "${BASE}/v1/kakao-mobility/directions" \
|
|||
- `duration` (second) → 분 환산
|
||||
- `fare.taxi`, `fare.toll` → 원
|
||||
- `priority` (요청한 값 echo)
|
||||
- `avoid` 요청 시 `toll` 등 회피 옵션 적용
|
||||
|
||||
### 6. 출력 포맷
|
||||
|
||||
|
|
@ -156,7 +158,7 @@ curl -fsS --get "${BASE}/v1/kakao-mobility/directions" \
|
|||
자동차 경로: (126.9706,37.5559) → (127.0276,37.4979)
|
||||
- 거리: 12.3km / 예상 소요시간: 25분
|
||||
- 통행료: 1,200원 / 예상 택시요금: 18,500원
|
||||
- 옵션: RECOMMEND
|
||||
- 옵션: RECOMMEND, avoid=toll
|
||||
- 조회 시각: 2026-05-23T14:00:00.000Z
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
- `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`)
|
||||
- `GET /v1/kakao-mobility/directions` — Kakao Mobility 자동차 길찾기(`KAKAO_REST_API_KEY`; `avoid=toll|motorway` 등 회피 옵션 지원)
|
||||
- `GET /v1/kosis/search` — KOSIS 통계표 검색(`KOSIS_API_KEY`)
|
||||
- `GET /v1/kosis/meta` — KOSIS 통계표 메타데이터(`KOSIS_API_KEY`)
|
||||
- `GET /v1/kosis/data` — KOSIS 통계 데이터 셀 조회(`KOSIS_API_KEY`)
|
||||
|
|
@ -66,7 +66,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-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-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": {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const KAKAO_CATEGORY_GROUP_CODES = new Set([
|
|||
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) {
|
||||
|
|
@ -128,6 +129,9 @@ function normalizeKakaoKeywordSearchQuery(query) {
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
@ -286,6 +290,16 @@ function normalizeKakaoMobilityDirectionsQuery(query) {
|
|||
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,
|
||||
|
|
@ -293,7 +307,8 @@ function normalizeKakaoMobilityDirectionsQuery(query) {
|
|||
priority,
|
||||
car_fuel: carFuel,
|
||||
car_hipass: carHipass,
|
||||
alternatives
|
||||
alternatives,
|
||||
avoid
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -384,6 +399,7 @@ async function fetchKakaoMobilityDirections({
|
|||
car_fuel,
|
||||
car_hipass,
|
||||
alternatives,
|
||||
avoid,
|
||||
apiKey,
|
||||
fetchImpl = global.fetch
|
||||
}) {
|
||||
|
|
@ -410,6 +426,9 @@ async function fetchKakaoMobilityDirections({
|
|||
if (alternatives !== null && alternatives !== undefined) {
|
||||
url.searchParams.set("alternatives", String(alternatives));
|
||||
}
|
||||
if (avoid) {
|
||||
url.searchParams.set("avoid", avoid);
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
|
|
@ -475,6 +494,7 @@ module.exports = {
|
|||
KAKAO_CATEGORY_GROUP_CODES,
|
||||
KAKAO_MOBILITY_PRIORITY,
|
||||
KAKAO_MOBILITY_CAR_FUEL,
|
||||
KAKAO_MOBILITY_AVOID,
|
||||
fetchKakaoLocalEndpoint,
|
||||
fetchKakaoMobilityDirections,
|
||||
normalizeKakaoKeywordSearchQuery,
|
||||
|
|
|
|||
|
|
@ -784,6 +784,13 @@ test("Kakao Map keyword search validates coordinate pairing and radius bounds",
|
|||
url: "/v1/kakao-map/search/keyword?q=hi&x=127.0&y=37.5&radius=99999"
|
||||
});
|
||||
assert.equal(badRadius.statusCode, 400);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test("Kakao Map category search rejects unsupported category group codes", async (t) => {
|
||||
|
|
@ -839,6 +846,39 @@ test("Kakao Map category search routes to /search/category.json with FD6 and coo
|
|||
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 = [];
|
||||
|
|
@ -947,6 +987,35 @@ test("Kakao Mobility directions endpoint injects KakaoAK, forwards priority/opti
|
|||
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 () => {
|
||||
|
|
@ -965,6 +1034,12 @@ test("Kakao Mobility directions endpoint validates coordinate, priority, and way
|
|||
});
|
||||
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="
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue