mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
🔀 Merge branch 'feature/#0' into feature/k-schoollunch-menu
NEIS 급식·생활쓰레기 프록시·문서·skill-docs 테스트 정렬 충돌을 해소했다. Made-with: Cursor
This commit is contained in:
commit
0b37193fd6
12 changed files with 424 additions and 149 deletions
|
|
@ -15,8 +15,7 @@
|
|||
## 먼저 필요한 것
|
||||
|
||||
- 인터넷 연결
|
||||
- 원본 API 접근 가능 환경
|
||||
- API 키 주입용 proxy 접근 가능 환경
|
||||
- `DATA_GO_KR_API_KEY`가 설정된 proxy 접근 가능 환경
|
||||
|
||||
## 기본 조회 예시
|
||||
|
||||
|
|
@ -24,10 +23,11 @@
|
|||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/household-waste/info' \
|
||||
--data-urlencode 'cond[SGG_NM::LIKE]=강남구' \
|
||||
--data-urlencode 'pageNo=1' \
|
||||
--data-urlencode 'numOfRows=100'
|
||||
--data-urlencode 'numOfRows=100' \
|
||||
--data-urlencode 'cond[SGG_NM::LIKE]=강남구'
|
||||
```
|
||||
|
||||
클라이언트는 **`cond[SGG_NM::LIKE]`** 와 **`pageNo` / `numOfRows`**(또는 `page_no` / `num_of_rows`)를 **함께** 넘긴다. `pageNo` / `numOfRows` 값은 **반드시 `1` / `100`** 이어야 하고, 그 외 값이나 숫자만으로 표현되지 않는 문자열이면 proxy가 **`400`** 을 반환하고 upstream을 호출하지 않는다. upstream에는 항상 `pageNo=1`, `numOfRows=100`만 전달된다. `returnType`은 항상 `json`으로 강제된다. 원본 API의 `cond[DAT_CRTR_YMD::*]`, `cond[DAT_UPDT_PNT::*]` 같은 부가 필터는 현재 지원하지 않는다 — 일반 사용자 질의("강남구 쓰레기 배출 요일")는 시군구 검색만으로 충분하기 때문이다.
|
||||
현재 proxy가 패스스루하는 파라미터는 `pageNo`, `numOfRows`, `cond[SGG_NM::LIKE]` 뿐이며, `returnType`은 항상 `json`으로 강제된다. `pageNo`는 정확히 `1`만 허용하고 `numOfRows`는 정확히 `100`만 허용한다. 원본 API의 `cond[DAT_CRTR_YMD::*]`, `cond[DAT_UPDT_PNT::*]` 같은 부가 필터는 현재 지원하지 않는다 — 일반 사용자 질의("강남구 쓰레기 배출 요일")는 시군구 검색만으로 충분하기 때문이다.
|
||||
|
||||
## 조회 흐름 권장 순서
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ client/skill -> k-skill-proxy -> upstream public API
|
|||
- `GET /v1/korean-stock/trade-info`
|
||||
- `GET /v1/opinet/around`
|
||||
- `GET /v1/opinet/detail`
|
||||
- `GET /v1/household-waste/info` (생활쓰레기 배출정보, `DATA_GO_KR_API_KEY`)
|
||||
- `GET /v1/neis/school-search` (나이스 학교기본정보, `KEDU_INFO_KEY`)
|
||||
- `GET /v1/neis/school-meal` (나이스 급식식단정보, `KEDU_INFO_KEY`)
|
||||
- `GET /B552584/:service/:operation` (허용된 AirKorea route passthrough)
|
||||
|
|
@ -42,7 +43,7 @@ client/skill -> k-skill-proxy -> upstream public API
|
|||
- `SEOUL_OPEN_API_KEY=...`
|
||||
- `HRFCO_OPEN_API_KEY=...`
|
||||
- `OPINET_API_KEY=...`
|
||||
- `DATA_GO_KR_API_KEY=...`
|
||||
- `DATA_GO_KR_API_KEY=...` (생활쓰레기 배출정보 upstream key)
|
||||
- `KEDU_INFO_KEY=...` (나이스 교육정보 개방 포털 Open API 인증키)
|
||||
- `KRX_API_KEY=...`
|
||||
- `KSKILL_PROXY_PORT=4020`
|
||||
|
|
@ -141,6 +142,17 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/opinet/detail' \
|
|||
--data-urlencode 'id=A0009905'
|
||||
```
|
||||
|
||||
생활쓰레기 배출정보 endpoint:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/household-waste/info' \
|
||||
--data-urlencode 'cond[SGG_NM::LIKE]=강남구' \
|
||||
--data-urlencode 'pageNo=1' \
|
||||
--data-urlencode 'numOfRows=100'
|
||||
```
|
||||
|
||||
이 endpoint 는 `DATA_GO_KR_API_KEY`를 프록시 서버에서만 주입하고 `returnType=json`을 강제합니다. `pageNo`는 정확히 `1`만 허용하고 `numOfRows`는 정확히 `100`만 허용합니다.
|
||||
|
||||
나이스 학교 검색·급식 endpoint (학교 급식 식단 스킬에서 사용):
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -121,25 +121,7 @@ korean-law list
|
|||
|
||||
`household-waste-info` 는 별도 설치 없이 `k-skill-proxy`의 `/v1/household-waste/info` 라우트를 호출하고, `serviceKey`(`DATA_GO_KR_API_KEY`)는 proxy 서버에서만 원본 API(`apis.data.go.kr/1741000/household_waste_info/info`)로 주입한다. 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다. 자세한 사용법은 [생활쓰레기 배출정보 조회 가이드](features/household-waste-info.md)를 본다.
|
||||
|
||||
### `korean-stock-search` proxy quickstart
|
||||
|
||||
`korean-stock-search` 는 로컬 MCP 설치 대신 **proxy first** 로 사용한다.
|
||||
|
||||
- 가장 빠른 smoke test 는 `curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' --data-urlencode 'q=삼성전자' --data-urlencode 'bas_dd=20260404'`
|
||||
- 검색 결과에서 `market`, `code` 를 확인한 뒤 `base-info` 또는 `trade-info` 로 이어간다.
|
||||
- 사용자 쪽 `KRX_API_KEY` 는 필요 없다. self-host proxy 운영자만 서버 환경변수 `KRX_API_KEY` 를 설정한다.
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
|
||||
--data-urlencode 'q=삼성전자' \
|
||||
--data-urlencode 'bas_dd=20260404'
|
||||
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/base-info' \
|
||||
--data-urlencode 'market=KOSPI' \
|
||||
--data-urlencode 'code=005930' \
|
||||
--data-urlencode 'bas_dd=20260404'
|
||||
```
|
||||
|
||||
`k-schoollunch-menu` 는 별도 설치 없이 `k-skill-proxy`의 `/v1/neis/school-search`, `/v1/neis/school-meal` 라우트를 호출하고, `KEDU_INFO_KEY`는 proxy 서버에서만 나이스 Open API `KEY`로 주입한다. 사용자 쪽 `KEDU_INFO_KEY` 가 불필요하다. 자세한 사용법은 [학교 급식 식단 조회 가이드](features/k-schoollunch-menu.md)를 본다.
|
||||
|
||||
### `olive-young-search` upstream CLI quickstart
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,6 @@ KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
|
|||
- `KRX_API_KEY`
|
||||
- `KSKILL_PROXY_BASE_URL`
|
||||
|
||||
`LAW_OC` 는 `korean-law-mcp` 가 법제처 Open API 를 호출할 때 쓰는 표준 변수명이다. 이 값은 로컬 CLI/로컬 MCP server 경로에서만 사용자 쪽에 필요하고, upstream remote MCP endpoint 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다. `DATA_GO_KR_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 키가 불필요하다. 생활쓰레기 배출정보 조회는 `k-skill-proxy`의 `/v1/household-waste/info` 라우트를 거쳐 `serviceKey`(`DATA_GO_KR_API_KEY`)를 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다. 한국 주식 정보 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. `KRX_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. 근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다. `OPINET_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. `KIPRIS_PLUS_API_KEY` 는 한국 특허 정보 검색 helper가 KIPRIS Plus Open API에 보낼 `ServiceKey` 값을 담는 표준 변수명이다. 공공데이터포털에서 복사한 percent-encoded key도 helper가 한 번 정규화한 뒤 요청한다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY`, `KMA_OPEN_API_KEY`, `AIR_KOREA_OPEN_API_KEY`, `HRFCO_OPEN_API_KEY`, `OPINET_API_KEY`, `DATA_GO_KR_API_KEY`, `KRX_API_KEY` 를 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 도 서울 지하철/한국 날씨 route가 실제 배포된 proxy URL 로만 넣는다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회는 이 값이 없으면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 사용한다.
|
||||
`LAW_OC` 는 `korean-law-mcp` 가 법제처 Open API 를 호출할 때 쓰는 표준 변수명이다. 이 값은 로컬 CLI/로컬 MCP server 경로에서만 사용자 쪽에 필요하고, upstream remote MCP endpoint 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다. `DATA_GO_KR_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하고, 생활쓰레기 배출정보 조회는 `k-skill-proxy`의 `/v1/household-waste/info` 라우트를 거쳐 `serviceKey`를 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다. 학교 급식·학교 검색(NEIS)은 프록시가 `KEDU_INFO_KEY` 로 나이스 Open API `KEY` 를 붙이므로 사용자 쪽 키가 불필요하다. `KEDU_INFO_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다. `OPINET_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY`, `AIR_KOREA_OPEN_API_KEY`, `HRFCO_OPEN_API_KEY`, `OPINET_API_KEY`, `DATA_GO_KR_API_KEY`, `KEDU_INFO_KEY` 를 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 은 서울 지하철 route가 실제 배포된 proxy URL 로만 넣는다. 미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보, 학교 급식 식단은 이 값이 없으면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 사용한다.
|
||||
|
||||
이 레포의 credential-bearing skill은 전부 이 정책을 전제로 작성한다. 자세한 공통 설치 절차는 [공통 설정 가이드](setup.md)를 본다.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# 공통 설정 가이드
|
||||
|
||||
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, 한국 특허 정보 검색의 KIPRIS Plus 경로용 `KIPRIS_PLUS_API_KEY`, self-host 프록시 운영용 서울 지하철/한국 날씨/미세먼지/한강홍수통제소 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다. 미세먼지, 한강 수위, 주유소 가격, 부동산 실거래가, 한국 주식 정보 조회, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회는 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다(단, hosted 프록시 운영 측에서 `DATA_GO_KR_API_KEY`·`KEDU_INFO_KEY` 등은 서버에 설정되어 있어야 한다).
|
||||
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, 한국 특허 정보 검색의 KIPRIS Plus 경로용 `KIPRIS_PLUS_API_KEY`, self-host 프록시 운영용 서울 지하철 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다. 한국 주식 정보 조회, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회는 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다(다만 hosted 프록시 운영 측에서는 `DATA_GO_KR_API_KEY`·`KEDU_INFO_KEY` 등이 서버에 설정되어 있어야 한다). 미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보, 부동산 실거래가, 학교 급식 식단은 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다.
|
||||
|
||||
## Credential resolution order
|
||||
|
||||
|
|
@ -34,7 +34,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
|
|||
|
||||
실제 값을 채운다.
|
||||
|
||||
서울 지하철 도착정보와 한국 날씨 조회는 hosted public route rollout 이 끝나기 전까지 `KSKILL_PROXY_BASE_URL` 을 self-host 또는 배포 확인이 끝난 proxy URL 로 채워야 한다. 미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다.
|
||||
서울 지하철 도착정보는 hosted public route rollout 이 끝나기 전까지 `KSKILL_PROXY_BASE_URL` 을 self-host 또는 배포 확인이 끝난 proxy URL 로 채워야 한다. 미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보, 학교 급식 식단은 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다. 미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다.
|
||||
|
||||
한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC` 는 `korean-law-mcp` 로컬 실행에 쓴다. 로컬 CLI/MCP 경로는 `LAW_OC` 를 채운 뒤 `npm install -g korean-law-mcp` 와 `korean-law list` 로 설치 상태를 확인한다.
|
||||
|
||||
|
|
@ -46,7 +46,7 @@ remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결한다.
|
|||
|
||||
근처 가장 싼 주유소 찾기는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다.
|
||||
|
||||
한국 특허 정보 검색의 KIPRIS Plus 경로용 `KIPRIS_PLUS_API_KEY` 는 helper가 읽는 표준 변수명이다. 실제 HTTP 요청에서는 같은 값을 `ServiceKey` 쿼리 파라미터로 보낸다. 공공데이터포털에서 복사한 percent-encoded key도 helper가 한 번 정규화해서 그대로 쓸 수 있다.
|
||||
생활쓰레기 배출정보 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다.
|
||||
|
||||
## 확인
|
||||
|
||||
|
|
@ -78,9 +78,11 @@ bash scripts/check-setup.sh
|
|||
| 한국 날씨 조회 | self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL` |
|
||||
| 사용자 위치 미세먼지 조회 | `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY` |
|
||||
| 한강 수위 정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
|
||||
| 생활쓰레기 배출정보 조회 | 사용자 시크릿 불필요 (프록시에 `DATA_GO_KR_API_KEY`가 설정된 hosted/self-host; API 호출 시 `pageNo=1`, `numOfRows=100` 필수) |
|
||||
| 생활쓰레기 배출정보 조회 | 사용자 시크릿 불필요 (프록시에 `DATA_GO_KR_API_KEY`가 설정된 hosted/self-host 사용) |
|
||||
| 학교 급식 식단 조회 | 사용자 시크릿 불필요 (프록시에 `KEDU_INFO_KEY`가 설정된 hosted/self-host 사용) |
|
||||
|
||||
생활쓰레기 배출정보 API 호출 시 `pageNo=1`, `numOfRows=100` 은 필수다.
|
||||
|
||||
## 다음에 볼 문서
|
||||
|
||||
- [SRT 예매 가이드](features/srt-booking.md)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ metadata:
|
|||
|
||||
- 기본 조회 단위는 시군구명(`SGG_NM`)이다.
|
||||
- 응답은 사용자에게 이해하기 쉬운 요약 형태로 정리한다.
|
||||
- Base URL은 원본 API(`https://apis.data.go.kr/1741000/household_waste_info`)를 기준으로 한다.
|
||||
- 기본 호출 URL은 proxy `https://k-skill-proxy.nomadamas.org/v1/household-waste/info` 를 기준으로 한다.
|
||||
- `serviceKey`(`DATA_GO_KR_API_KEY`)만 proxy 서버에서 주입/관리한다.
|
||||
|
||||
## When to use
|
||||
|
|
@ -31,8 +31,7 @@ metadata:
|
|||
|
||||
- 인터넷 연결
|
||||
- `curl`, `python3` 사용 가능 환경
|
||||
- 원본 API 접근 가능 환경
|
||||
- API 키 주입용 proxy 접근 가능 환경
|
||||
- `DATA_GO_KR_API_KEY`가 설정된 proxy 접근 가능 환경
|
||||
|
||||
## Credential requirements
|
||||
|
||||
|
|
@ -60,6 +59,10 @@ metadata:
|
|||
|
||||
현재 proxy가 지원하는 쿼리 파라미터:
|
||||
|
||||
- `serviceKey`: proxy가 서버 측에서 주입하는 인증키 (`DATA_GO_KR_API_KEY`) — 클라이언트에서 전달 금지
|
||||
- `pageNo`: 필수, 정확히 `1`만 허용
|
||||
- `numOfRows`: 필수, 정확히 `100`만 허용
|
||||
- `returnType`: proxy가 항상 `json`으로 강제 — 클라이언트가 값을 보내도 무시된다
|
||||
- `cond[SGG_NM::LIKE]`: 시군구명 포함 검색 (필수)
|
||||
- `pageNo` / `numOfRows`(또는 `page_no` / `num_of_rows`): **필수**, 값은 **반드시 `1` / `100`** — 그 외 값·비정수(숫자만 아닌) 문자열은 **`400`**. upstream에는 항상 1페이지·100건만 전달한다.
|
||||
- `returnType`: proxy가 항상 `json`으로 강제 — 클라이언트가 값을 보내도 무시된다
|
||||
|
|
@ -88,10 +91,11 @@ proxy가 `serviceKey`를 서버 측에서 주입한 뒤 원본 API로 전달한
|
|||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/household-waste/info' \
|
||||
--data-urlencode "cond[SGG_NM::LIKE]=강남구" \
|
||||
--data-urlencode "pageNo=1" \
|
||||
--data-urlencode "numOfRows=100"
|
||||
--data-urlencode "numOfRows=100" \
|
||||
--data-urlencode "cond[SGG_NM::LIKE]=강남구"
|
||||
```
|
||||
|
||||
`returnType`은 proxy가 항상 `json`으로 강제하므로 클라이언트에서 별도로 보낼 필요가 없다.
|
||||
`returnType`은 proxy가 항상 `json`으로 강제하므로 클라이언트에서 별도로 보낼 필요가 없다. `pageNo`는 정확히 `1`, `numOfRows`는 정확히 `100`만 허용한다.
|
||||
|
||||
`KSKILL_PROXY_BASE_URL`이 있으면 그 값을 사용하고, 없으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 사용한다.
|
||||
|
||||
|
|
@ -116,8 +120,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/household-waste/info' \
|
|||
- 프록시 서버에 `DATA_GO_KR_API_KEY`가 없거나 만료된 경우 (`serviceKey` 주입 실패)
|
||||
- 검색 지역명이 API 데이터와 불일치하여 결과가 비는 경우
|
||||
- 공공데이터 API 일시 장애/트래픽 제한
|
||||
- 필수 파라미터 누락(`cond[SGG_NM::LIKE]`, 또는 `pageNo` / `numOfRows` 미전달)
|
||||
- `pageNo` / `numOfRows` 값이 `1` / `100`이 아니거나, 숫자만으로 표현되지 않은 문자열인 경우(proxy `400`, upstream 미호출)
|
||||
- 필수 파라미터 누락(`cond[SGG_NM::LIKE]`, `pageNo`, `numOfRows`)
|
||||
|
||||
## Notes
|
||||
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
|
|||
|
||||
유저에게 물어서 실제 값을 채운다.
|
||||
|
||||
서울 지하철 도착정보와 한국 날씨 조회는 hosted public route rollout 이 끝나기 전까지 `KSKILL_PROXY_BASE_URL` 을 self-host 또는 배포 확인이 끝난 proxy URL 로 채운다. 미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다.
|
||||
서울 지하철 도착정보는 hosted public route rollout 이 끝나기 전까지 `KSKILL_PROXY_BASE_URL` 을 self-host 또는 배포 확인이 끝난 proxy URL 로 채운다. 미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보, 학교 급식 식단은 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다. 미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다.
|
||||
|
||||
한국 법령 검색은 로컬 `korean-law-mcp` 경로를 쓸 때만 `LAW_OC` 를 채운다. remote endpoint는 사용자 `LAW_OC` 없이 `url`만 등록하면 되고, 기존 경로 장애 시에는 `법망`(`https://api.beopmang.org`)을 fallback으로 안내한다.
|
||||
|
||||
|
|
@ -87,7 +87,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
|
|||
|
||||
생활쓰레기 배출정보 조회는 `k-skill-proxy`의 `/v1/household-waste/info` 라우트를 호출하고, `serviceKey`(`DATA_GO_KR_API_KEY`)는 proxy 서버에서 주입/관리하므로 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다.
|
||||
|
||||
학교 급식 식단 조회는 `k-skill-proxy`의 `/v1/neis/school-search`·`/v1/neis/school-meal`을 호출하고, `KEDU_INFO_KEY`는 프록시 서버에만 두므로 사용자 쪽에 둘 필요가 없다.
|
||||
학교 급식 식단 조회는 `k-skill-proxy`의 `/v1/neis/school-search`, `/v1/neis/school-meal` 라우트를 호출하고, `KEDU_INFO_KEY`는 proxy 서버에서 주입/관리하므로 사용자 쪽 `KEDU_INFO_KEY` 가 불필요하다.
|
||||
|
||||
근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다.
|
||||
|
||||
|
|
@ -106,9 +106,8 @@ chmod 0600 ~/.config/k-skill/secrets.env
|
|||
- 한국 법령 검색 remote endpoint: 사용자 `LAW_OC` 없이 `url`만 등록, 장애 시 `법망` fallback
|
||||
- 한국 부동산 실거래가 조회: 사용자 시크릿 불필요 (기본 hosted proxy 사용)
|
||||
- 한국 특허 정보 검색: `KIPRIS_PLUS_API_KEY`
|
||||
- 한국 주식 정보 조회: 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KRX_API_KEY`)
|
||||
- 생활쓰레기 배출정보 조회: 사용자 시크릿 불필요 (`serviceKey`는 proxy 서버 주입, 호출 시 `pageNo=1`·`numOfRows=100` 필수)
|
||||
- 학교 급식 식단 조회: 사용자 시크릿 불필요 (`KEDU_INFO_KEY`는 proxy 서버만)
|
||||
- 생활쓰레기 배출정보 조회: 사용자 시크릿 불필요 (`serviceKey`(`DATA_GO_KR_API_KEY`)는 proxy 서버 주입)
|
||||
- 학교 급식 식단 조회: 사용자 시크릿 불필요 (`KEDU_INFO_KEY`는 proxy 서버 주입)
|
||||
- 근처 가장 싼 주유소 찾기: 사용자 시크릿 불필요 (기본 hosted proxy 사용)
|
||||
- 서울 지하철: self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL`
|
||||
- 한국 날씨: self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL`
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# k-skill-proxy
|
||||
|
||||
`k-skill`용 Fastify 기반 프록시 서버입니다. AirKorea 미세먼지 조회, 기상청 단기예보, 서울 지하철 실시간 도착정보, 한강홍수통제소 수위 정보를 감싸고, 이후 무료/공공 API adapter를 추가하는 베이스로 씁니다.
|
||||
`k-skill`용 Fastify 기반 프록시 서버입니다. AirKorea 미세먼지 조회, 서울 지하철 실시간 도착정보, 한강홍수통제소 수위 정보, 생활쓰레기 배출정보를 감싸고, 이후 무료/공공 API adapter를 추가하는 베이스로 씁니다.
|
||||
|
||||
## 현재 제공 엔드포인트
|
||||
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
- `GET /v1/korea-weather/forecast`
|
||||
- `GET /v1/seoul-subway/arrival`
|
||||
- `GET /v1/han-river/water-level`
|
||||
- `GET /v1/household-waste/info` — 생활쓰레기 배출정보(`DATA_GO_KR_API_KEY`; `pageNo=1`, `numOfRows=100` 필수)
|
||||
- `GET /v1/household-waste/info` — 생활쓰레기 배출정보 조회 (`DATA_GO_KR_API_KEY` 서버 주입)
|
||||
- `GET /v1/neis/school-search` — 나이스 학교기본정보(교육청명·학교명 검색)
|
||||
- `GET /v1/neis/school-meal` — 나이스 급식식단정보(일자별 메뉴)
|
||||
- `GET /v1/korean-stock/search`
|
||||
|
|
@ -22,6 +22,7 @@
|
|||
- `KMA_OPEN_API_KEY` — 프록시 서버 쪽 기상청 단기예보 upstream key
|
||||
- `SEOUL_OPEN_API_KEY` — 프록시 서버 쪽 서울 열린데이터 광장 upstream key
|
||||
- `HRFCO_OPEN_API_KEY` — 프록시 서버 쪽 한강홍수통제소 upstream key
|
||||
- `DATA_GO_KR_API_KEY` — 프록시 서버 쪽 공공데이터포털 upstream key (`household-waste/info`)
|
||||
- `KEDU_INFO_KEY` — 프록시 서버 쪽 나이스(NEIS) 교육정보 개방 포털 Open API 인증키 (`school-search`, `school-meal`)
|
||||
- `KRX_API_KEY` — 프록시 서버 쪽 KRX Open API upstream key
|
||||
- `KSKILL_PROXY_HOST` — 기본 `127.0.0.1`
|
||||
|
|
@ -41,6 +42,8 @@ node packages/k-skill-proxy/src/server.js
|
|||
|
||||
환경변수(`AIR_KOREA_OPEN_API_KEY` 등)가 이미 설정되어 있거나 `~/.config/k-skill/secrets.env`를 source한 상태에서 실행한다.
|
||||
|
||||
> 빠뜨리기 쉬운 값: 생활쓰레기 route는 `DATA_GO_KR_API_KEY`, 학교 검색/급식 route는 `KEDU_INFO_KEY`가 프록시 서버 쪽에 있어야 하며, 누락 시 각 route가 `503 upstream_not_configured`를 반환한다.
|
||||
|
||||
서울 지하철 도착정보 예시:
|
||||
|
||||
```bash
|
||||
|
|
@ -101,6 +104,34 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/korean-stock/search' \
|
|||
|
||||
프록시는 내부적으로 `waterlevel/info.json` 으로 관측소를 해석하고, `waterlevel/list/10M/{WLOBSCD}.json` 으로 최신 수위/유량을 조회합니다. 한국 주식 route는 KRX Open API에 `AUTH_KEY` 헤더를 서버 쪽에서만 주입합니다.
|
||||
|
||||
생활쓰레기 배출정보 예시:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'http://127.0.0.1:4020/v1/household-waste/info' \
|
||||
--data-urlencode 'cond[SGG_NM::LIKE]=강남구' \
|
||||
--data-urlencode 'pageNo=1' \
|
||||
--data-urlencode 'numOfRows=100'
|
||||
```
|
||||
|
||||
프록시는 `serviceKey`를 `DATA_GO_KR_API_KEY`에서만 주입하고 `returnType=json`을 강제합니다. `pageNo`는 정확히 `1`만 허용하고 `numOfRows`는 정확히 `100`만 허용합니다.
|
||||
|
||||
학교 검색 예시:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'http://127.0.0.1:4020/v1/neis/school-search' \
|
||||
--data-urlencode 'educationOffice=서울특별시교육청' \
|
||||
--data-urlencode 'schoolName=미래초등학교'
|
||||
```
|
||||
|
||||
학교 급식 예시:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'http://127.0.0.1:4020/v1/neis/school-meal' \
|
||||
--data-urlencode 'educationOfficeCode=B10' \
|
||||
--data-urlencode 'schoolCode=7010123' \
|
||||
--data-urlencode 'mealDate=20260410'
|
||||
```
|
||||
|
||||
|
||||
## PM2 실행
|
||||
|
||||
|
|
|
|||
|
|
@ -126,6 +126,39 @@ function convertLatLonToKmaGrid(latitude, longitude) {
|
|||
};
|
||||
}
|
||||
|
||||
function trimSingleQueryValueOrNull(value, fieldName) {
|
||||
if (Array.isArray(value)) {
|
||||
throw new Error(`${fieldName} must be provided exactly once.`);
|
||||
}
|
||||
return trimOrNull(value);
|
||||
}
|
||||
|
||||
function trimSingleAliasedQueryValueOrNull(query, aliases, fieldName) {
|
||||
const providedAliases = aliases.filter((alias) => Object.hasOwn(query, alias));
|
||||
if (providedAliases.length > 1) {
|
||||
throw new Error(`${fieldName} must be provided exactly once.`);
|
||||
}
|
||||
|
||||
if (providedAliases.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimSingleQueryValueOrNull(query[providedAliases[0]], fieldName);
|
||||
}
|
||||
|
||||
function requireFixedQueryInteger(query, aliases, fieldName, expectedValue) {
|
||||
const rawValue = trimSingleAliasedQueryValueOrNull(query, aliases, fieldName);
|
||||
if (rawValue === null) {
|
||||
throw new Error(`${fieldName} is required and must be exactly ${expectedValue}.`);
|
||||
}
|
||||
|
||||
if (!/^\d+$/.test(rawValue) || Number.parseInt(rawValue, 10) !== expectedValue) {
|
||||
throw new Error(`${fieldName} must be exactly ${expectedValue}.`);
|
||||
}
|
||||
|
||||
return String(expectedValue);
|
||||
}
|
||||
|
||||
function buildConfig(env = process.env) {
|
||||
return {
|
||||
host: env.KSKILL_PROXY_HOST || "127.0.0.1",
|
||||
|
|
@ -486,6 +519,22 @@ function normalizeRegionCodeQuery(query) {
|
|||
return { q };
|
||||
}
|
||||
|
||||
function normalizeHouseholdWasteInfoQuery(query) {
|
||||
const sggNm = trimSingleQueryValueOrNull(query["cond[SGG_NM::LIKE]"], "cond[SGG_NM::LIKE]");
|
||||
if (!sggNm) {
|
||||
throw new Error("cond[SGG_NM::LIKE] is required");
|
||||
}
|
||||
|
||||
const pageNo = requireFixedQueryInteger(query, ["pageNo", "page_no"], "pageNo", 1);
|
||||
const numOfRows = requireFixedQueryInteger(query, ["numOfRows", "num_of_rows"], "numOfRows", 100);
|
||||
|
||||
return {
|
||||
sggNm,
|
||||
pageNo,
|
||||
numOfRows
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHanRiverWaterLevelQuery(query) {
|
||||
const stationName = trimOrNull(query.stationName ?? query.station_name ?? query.station);
|
||||
const stationCode = trimOrNull(query.stationCode ?? query.station_code ?? query.wlobscd);
|
||||
|
|
@ -854,51 +903,6 @@ async function proxyNeisSchoolInfoRequest({
|
|||
|
||||
|
||||
|
||||
function validateHouseholdWastePaginationQuery(query) {
|
||||
const HOUSEHOLD_WASTE_PAGINATION_RULE =
|
||||
"Household waste info requires pageNo=1 and numOfRows=100 (page_no and num_of_rows accepted). Other values or non-digit strings return 400.";
|
||||
|
||||
const rawPage = query.pageNo ?? query.page_no;
|
||||
const rawNum = query.numOfRows ?? query.num_of_rows;
|
||||
const pageProvided =
|
||||
rawPage !== undefined && rawPage !== null && String(rawPage).trim() !== "";
|
||||
const numProvided =
|
||||
rawNum !== undefined && rawNum !== null && String(rawNum).trim() !== "";
|
||||
|
||||
if (!pageProvided || !numProvided) {
|
||||
return { ok: false, message: HOUSEHOLD_WASTE_PAGINATION_RULE };
|
||||
}
|
||||
|
||||
const parseDigitsOnlyUInt = (raw, label) => {
|
||||
const s = String(raw).trim();
|
||||
if (!/^\d+$/.test(s)) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Invalid ${label} for household waste info: use digits only; pageNo must be 1 and numOfRows must be 100.`
|
||||
};
|
||||
}
|
||||
return { ok: true, value: Number.parseInt(s, 10) };
|
||||
};
|
||||
|
||||
const pageParsed = parseDigitsOnlyUInt(rawPage, "pageNo");
|
||||
if (!pageParsed.ok) {
|
||||
return pageParsed;
|
||||
}
|
||||
if (pageParsed.value !== 1) {
|
||||
return { ok: false, message: HOUSEHOLD_WASTE_PAGINATION_RULE };
|
||||
}
|
||||
|
||||
const numParsed = parseDigitsOnlyUInt(rawNum, "numOfRows");
|
||||
if (!numParsed.ok) {
|
||||
return numParsed;
|
||||
}
|
||||
if (numParsed.value !== 100) {
|
||||
return { ok: false, message: HOUSEHOLD_WASTE_PAGINATION_RULE };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function buildServer({ env = process.env, provider = null, now = () => new Date() } = {}) {
|
||||
const config = buildConfig(env);
|
||||
const cache = createMemoryCache();
|
||||
|
|
@ -1555,32 +1559,21 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
});
|
||||
|
||||
app.get("/v1/household-waste/info", async (request, reply) => {
|
||||
const query = request.query || {};
|
||||
const sggNm = query["cond[SGG_NM::LIKE]"];
|
||||
let normalized;
|
||||
|
||||
if (!sggNm || !sggNm.trim()) {
|
||||
try {
|
||||
normalized = normalizeHouseholdWasteInfoQuery(request.query || {});
|
||||
} catch (error) {
|
||||
reply.code(400);
|
||||
return {
|
||||
error: "bad_request",
|
||||
message: "cond[SGG_NM::LIKE] is required"
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
|
||||
const paginationCheck = validateHouseholdWastePaginationQuery(query);
|
||||
if (!paginationCheck.ok) {
|
||||
reply.code(400);
|
||||
return {
|
||||
error: "bad_request",
|
||||
message: paginationCheck.message
|
||||
};
|
||||
}
|
||||
|
||||
const pageNo = "1";
|
||||
const numOfRows = "100";
|
||||
|
||||
const cacheKey = makeCacheKey({
|
||||
route: "household-waste-info",
|
||||
sggNm: sggNm.trim()
|
||||
...normalized
|
||||
});
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
|
|
@ -1604,10 +1597,10 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
|
||||
const url = new URL("https://apis.data.go.kr/1741000/household_waste_info/info");
|
||||
url.searchParams.set("serviceKey", config.molitApiKey);
|
||||
url.searchParams.set("pageNo", pageNo);
|
||||
url.searchParams.set("numOfRows", numOfRows);
|
||||
url.searchParams.set("pageNo", normalized.pageNo);
|
||||
url.searchParams.set("numOfRows", normalized.numOfRows);
|
||||
url.searchParams.set("returnType", "json");
|
||||
url.searchParams.set("cond[SGG_NM::LIKE]", sggNm.trim());
|
||||
url.searchParams.set("cond[SGG_NM::LIKE]", normalized.sggNm);
|
||||
|
||||
let upstreamData;
|
||||
try {
|
||||
|
|
@ -1632,7 +1625,11 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
|
||||
const payload = {
|
||||
...upstreamData,
|
||||
query: { sgg_nm: sggNm.trim(), page_no: pageNo, num_of_rows: numOfRows },
|
||||
query: {
|
||||
sgg_nm: normalized.sggNm,
|
||||
page_no: normalized.pageNo,
|
||||
num_of_rows: normalized.numOfRows
|
||||
},
|
||||
proxy: {
|
||||
name: config.proxyName,
|
||||
cache: { hit: false, ttl_ms: config.cacheTtlMs },
|
||||
|
|
@ -2178,6 +2175,7 @@ module.exports = {
|
|||
normalizeOpinetDetailQuery,
|
||||
normalizeNeisSchoolMealQuery,
|
||||
normalizeNeisSchoolSearchQuery,
|
||||
normalizeHouseholdWasteInfoQuery,
|
||||
normalizeRealEstateQuery,
|
||||
normalizeRegionCodeQuery,
|
||||
normalizeSeoulSubwayQuery,
|
||||
|
|
|
|||
|
|
@ -1787,15 +1787,42 @@ test("neis school-search proxies schoolInfo and resolves 교육청 이름", asyn
|
|||
assert.ok(decodeURIComponent(fetchedUrl).includes("미래초등학교"));
|
||||
});
|
||||
|
||||
test("household waste info endpoint requires SGG_NM filter", async (t) => {
|
||||
function buildHouseholdWasteTestApp(t, envOverrides = {}) {
|
||||
const app = buildServer({
|
||||
env: { DATA_GO_KR_API_KEY: "test-key" }
|
||||
env: {
|
||||
DATA_GO_KR_API_KEY: "test-key",
|
||||
...envOverrides
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
function mockHouseholdWasteJsonFetch(t, body = { response: { body: { items: [] } } }, status = 200) {
|
||||
const originalFetch = global.fetch;
|
||||
const fetchCalls = [];
|
||||
global.fetch = async (url) => {
|
||||
fetchCalls.push(String(url));
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" }
|
||||
});
|
||||
};
|
||||
|
||||
t.after(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
return fetchCalls;
|
||||
}
|
||||
|
||||
test("household waste info endpoint requires SGG_NM filter", async (t) => {
|
||||
const app = buildHouseholdWasteTestApp(t);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/household-waste/info"
|
||||
|
|
@ -1805,6 +1832,52 @@ test("household waste info endpoint requires SGG_NM filter", async (t) => {
|
|||
assert.equal(response.json().error, "bad_request");
|
||||
});
|
||||
|
||||
test("household waste info endpoint rejects duplicated SGG_NM filters before upstream fetch", async (t) => {
|
||||
const fetchCalls = mockHouseholdWasteJsonFetch(t);
|
||||
const app = buildHouseholdWasteTestApp(t);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EA%B0%95%EB%82%A8%EA%B5%AC&cond%5BSGG_NM%3A%3ALIKE%5D=%EC%84%9C%EC%B4%88%EA%B5%AC&pageNo=1&numOfRows=100"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.equal(response.json().error, "bad_request");
|
||||
assert.match(response.json().message, /cond\[SGG_NM::LIKE\]/i);
|
||||
assert.equal(fetchCalls.length, 0);
|
||||
});
|
||||
|
||||
|
||||
test("household waste info endpoint requires pageNo before upstream fetch", async (t) => {
|
||||
const fetchCalls = mockHouseholdWasteJsonFetch(t);
|
||||
const app = buildHouseholdWasteTestApp(t);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EA%B0%95%EB%82%A8%EA%B5%AC&numOfRows=100"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.equal(response.json().error, "bad_request");
|
||||
assert.match(response.json().message, /pageNo/i);
|
||||
assert.equal(fetchCalls.length, 0);
|
||||
});
|
||||
|
||||
test("household waste info endpoint requires numOfRows before upstream fetch", async (t) => {
|
||||
const fetchCalls = mockHouseholdWasteJsonFetch(t);
|
||||
const app = buildHouseholdWasteTestApp(t);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EA%B0%95%EB%82%A8%EA%B5%AC&pageNo=1"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.equal(response.json().error, "bad_request");
|
||||
assert.match(response.json().message, /numOfRows/i);
|
||||
assert.equal(fetchCalls.length, 0);
|
||||
});
|
||||
|
||||
test("household waste info endpoint reports 503 when DATA_GO_KR_API_KEY is missing", async (t) => {
|
||||
const app = buildServer();
|
||||
|
||||
|
|
@ -1840,41 +1913,24 @@ test("household waste info endpoint requires pageNo and numOfRows with cond", as
|
|||
});
|
||||
|
||||
test("household waste info endpoint injects serviceKey, forces returnType=json, and caches", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
const fetchCalls = [];
|
||||
global.fetch = async (url) => {
|
||||
fetchCalls.push(String(url));
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
response: {
|
||||
body: {
|
||||
items: [
|
||||
{
|
||||
SGG_NM: "강남구",
|
||||
MNG_ZONE_NM: "역삼1동",
|
||||
EMSN_PLC: "지정장소",
|
||||
LF_WST_EMSN_DOW: "월,수,금",
|
||||
LF_WST_EMSN_BGNG_TM: "18:00",
|
||||
LF_WST_EMSN_END_TM: "23:00"
|
||||
}
|
||||
]
|
||||
const fetchCalls = mockHouseholdWasteJsonFetch(t, {
|
||||
response: {
|
||||
body: {
|
||||
items: [
|
||||
{
|
||||
SGG_NM: "강남구",
|
||||
MNG_ZONE_NM: "역삼1동",
|
||||
EMSN_PLC: "지정장소",
|
||||
LF_WST_EMSN_DOW: "월,수,금",
|
||||
LF_WST_EMSN_BGNG_TM: "18:00",
|
||||
LF_WST_EMSN_END_TM: "23:00"
|
||||
}
|
||||
}
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
DATA_GO_KR_API_KEY: "test-key",
|
||||
KSKILL_PROXY_CACHE_TTL_MS: "60000"
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
const app = buildHouseholdWasteTestApp(t, {
|
||||
KSKILL_PROXY_CACHE_TTL_MS: "60000"
|
||||
});
|
||||
|
||||
const url =
|
||||
|
|
@ -1994,13 +2050,9 @@ test("household waste info endpoint ignores user-supplied returnType override",
|
|||
});
|
||||
};
|
||||
|
||||
const app = buildServer({
|
||||
env: { DATA_GO_KR_API_KEY: "test-key" }
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
const app = buildHouseholdWasteTestApp(t);
|
||||
t.after(() => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
|
|
@ -2012,6 +2064,96 @@ test("household waste info endpoint ignores user-supplied returnType override",
|
|||
assert.equal(new URL(capturedUrl).searchParams.get("returnType"), "json");
|
||||
});
|
||||
|
||||
test("household waste info endpoint rejects non-numeric pageNo before upstream fetch", async (t) => {
|
||||
const fetchCalls = mockHouseholdWasteJsonFetch(t);
|
||||
const app = buildHouseholdWasteTestApp(t);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EA%B0%95%EB%82%A8%EA%B5%AC&pageNo=abc&numOfRows=100"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.equal(response.json().error, "bad_request");
|
||||
assert.match(response.json().message, /pageNo/i);
|
||||
assert.equal(fetchCalls.length, 0);
|
||||
});
|
||||
|
||||
test("household waste info endpoint rejects pageNo values other than 1 before upstream fetch", async (t) => {
|
||||
const fetchCalls = mockHouseholdWasteJsonFetch(t);
|
||||
const app = buildHouseholdWasteTestApp(t);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EA%B0%95%EB%82%A8%EA%B5%AC&pageNo=2&numOfRows=100"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.equal(response.json().error, "bad_request");
|
||||
assert.match(response.json().message, /pageNo/i);
|
||||
assert.equal(fetchCalls.length, 0);
|
||||
});
|
||||
|
||||
test("household waste info endpoint rejects numOfRows values other than 100 before upstream fetch", async (t) => {
|
||||
const fetchCalls = mockHouseholdWasteJsonFetch(t);
|
||||
const app = buildHouseholdWasteTestApp(t);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EA%B0%95%EB%82%A8%EA%B5%AC&pageNo=1&numOfRows=20"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.equal(response.json().error, "bad_request");
|
||||
assert.match(response.json().message, /numOfRows/i);
|
||||
assert.equal(fetchCalls.length, 0);
|
||||
});
|
||||
|
||||
test("household waste info endpoint rejects duplicated pageNo values before upstream fetch", async (t) => {
|
||||
const fetchCalls = mockHouseholdWasteJsonFetch(t);
|
||||
const app = buildHouseholdWasteTestApp(t);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EA%B0%95%EB%82%A8%EA%B5%AC&pageNo=1&pageNo=2&numOfRows=100"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.equal(response.json().error, "bad_request");
|
||||
assert.match(response.json().message, /pageNo/i);
|
||||
assert.equal(fetchCalls.length, 0);
|
||||
});
|
||||
|
||||
test("household waste info endpoint rejects mixed pageNo aliases before upstream fetch", async (t) => {
|
||||
const fetchCalls = mockHouseholdWasteJsonFetch(t);
|
||||
const app = buildHouseholdWasteTestApp(t);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EA%B0%95%EB%82%A8%EA%B5%AC&pageNo=1&page_no=2&numOfRows=100"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.equal(response.json().error, "bad_request");
|
||||
assert.match(response.json().message, /pageNo/i);
|
||||
assert.equal(fetchCalls.length, 0);
|
||||
});
|
||||
|
||||
test("household waste info endpoint rejects mixed numOfRows aliases before upstream fetch", async (t) => {
|
||||
const fetchCalls = mockHouseholdWasteJsonFetch(t);
|
||||
const app = buildHouseholdWasteTestApp(t);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EA%B0%95%EB%82%A8%EA%B5%AC&pageNo=1&numOfRows=100&num_of_rows=20"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.equal(response.json().error, "bad_request");
|
||||
assert.match(response.json().message, /numOfRows/i);
|
||||
assert.equal(fetchCalls.length, 0);
|
||||
});
|
||||
|
||||
test("household waste info endpoint surfaces upstream non-200 as 502", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async () => new Response("oops", { status: 500 });
|
||||
|
|
|
|||
|
|
@ -143,6 +143,16 @@ test("root npm test script includes the skill docs regression suite", () => {
|
|||
assert.match(packageJson.scripts.test, /node --test scripts\/skill-docs\.test\.js/);
|
||||
});
|
||||
|
||||
test("validate-skills ignores hidden metadata directories", () => {
|
||||
const result = childProcess.spawnSync("bash", ["scripts/validate-skills.sh"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8"
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.match(result.stdout, /skill layout looks valid/);
|
||||
});
|
||||
|
||||
test("README advertises OpenClaw among the supported coding agents", () => {
|
||||
const readme = read("README.md");
|
||||
|
||||
|
|
@ -208,6 +218,101 @@ test("repository docs advertise the kakaotalk-mac skill", () => {
|
|||
assert.match(install, /--skill kakaotalk-mac/);
|
||||
});
|
||||
|
||||
test("proxy docs keep KEDU_INFO_KEY server-only and document household-waste env requirements", () => {
|
||||
const secretsExample = read(path.join("examples", "secrets.env.example"));
|
||||
const proxyReadme = read(path.join("packages", "k-skill-proxy", "README.md"));
|
||||
const proxyFeatureDoc = read(path.join("docs", "features", "k-skill-proxy.md"));
|
||||
|
||||
assert.doesNotMatch(secretsExample, /^KEDU_INFO_KEY=/m);
|
||||
|
||||
assert.match(proxyReadme, /GET \/v1\/household-waste\/info/);
|
||||
assert.match(proxyReadme, /DATA_GO_KR_API_KEY/);
|
||||
|
||||
assert.match(proxyFeatureDoc, /GET \/v1\/household-waste\/info/);
|
||||
assert.match(proxyFeatureDoc, /DATA_GO_KR_API_KEY/);
|
||||
});
|
||||
|
||||
|
||||
test("household-waste and proxy docs lock the narrowed household-waste curl contract", () => {
|
||||
const skill = read(path.join("household-waste-info", "SKILL.md"));
|
||||
const featureDoc = read(path.join("docs", "features", "household-waste-info.md"));
|
||||
const proxyReadme = read(path.join("packages", "k-skill-proxy", "README.md"));
|
||||
const proxyFeatureDoc = read(path.join("docs", "features", "k-skill-proxy.md"));
|
||||
|
||||
for (const doc of [skill, featureDoc, proxyReadme, proxyFeatureDoc]) {
|
||||
assert.match(doc, /pageNo=1/);
|
||||
assert.match(doc, /numOfRows=100/);
|
||||
assert.match(doc, /pageNo[^\n]*정확히 [`']?1[`']?만 허용/);
|
||||
assert.match(doc, /numOfRows[^\n]*정확히 [`']?100[`']?만 허용/);
|
||||
}
|
||||
});
|
||||
|
||||
test("proxy package README documents both NEIS curl steps", () => {
|
||||
const proxyReadme = read(path.join("packages", "k-skill-proxy", "README.md"));
|
||||
|
||||
assert.match(proxyReadme, /\/v1\/neis\/school-search/);
|
||||
assert.match(proxyReadme, /educationOffice=서울특별시교육청/);
|
||||
assert.match(proxyReadme, /schoolName=미래초등학교/);
|
||||
assert.match(proxyReadme, /\/v1\/neis\/school-meal/);
|
||||
assert.match(proxyReadme, /educationOfficeCode=B10/);
|
||||
assert.match(proxyReadme, /schoolCode=7010123/);
|
||||
assert.match(proxyReadme, /mealDate=20260410/);
|
||||
});
|
||||
|
||||
test("setup guide lists hosted proxy skill coverage including household waste and school lunch", () => {
|
||||
const setup = read(path.join("docs", "setup.md"));
|
||||
|
||||
assert.match(
|
||||
setup,
|
||||
/미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보, 부동산 실거래가, 학교 급식 식단은 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다\./,
|
||||
);
|
||||
assert.match(
|
||||
setup,
|
||||
/\| 생활쓰레기 배출정보 조회 \| 사용자 시크릿 불필요 \(프록시에 `DATA_GO_KR_API_KEY`가 설정된 hosted\/self-host 사용\) \|/,
|
||||
);
|
||||
assert.match(
|
||||
setup,
|
||||
/미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보, 학교 급식 식단은 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path\(`k-skill-proxy\.nomadamas\.org`\)를 그대로 쓴다\./,
|
||||
);
|
||||
assert.match(
|
||||
setup,
|
||||
/\| 학교 급식 식단 조회 \| 사용자 시크릿 불필요 \(프록시에 `KEDU_INFO_KEY`가 설정된 hosted\/self-host 사용\) \|/,
|
||||
);
|
||||
assert.match(setup, /\[생활쓰레기 배출정보 조회 가이드\]\(features\/household-waste-info\.md\)/);
|
||||
assert.match(setup, /\[학교 급식 식단 조회 가이드\]\(features\/k-schoollunch-menu\.md\)/);
|
||||
});
|
||||
|
||||
test("k-skill setup skill keeps hosted proxy guidance aligned for household waste and school lunch", () => {
|
||||
const skill = read(path.join("k-skill-setup", "SKILL.md"));
|
||||
|
||||
assert.match(
|
||||
skill,
|
||||
/미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보, 학교 급식 식단은 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path\(`k-skill-proxy\.nomadamas\.org`\)를 그대로 쓴다\./,
|
||||
);
|
||||
assert.match(
|
||||
skill,
|
||||
/생활쓰레기 배출정보 조회: 사용자 시크릿 불필요 \(`serviceKey`\(`DATA_GO_KR_API_KEY`\)는 proxy 서버 주입\)/,
|
||||
);
|
||||
assert.match(
|
||||
skill,
|
||||
/학교 급식 식단 조회: 사용자 시크릿 불필요 \(`KEDU_INFO_KEY`는 proxy 서버 주입\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("security and install docs keep school lunch on the hosted proxy / no-user-key path", () => {
|
||||
const security = read(path.join("docs", "security-and-secrets.md"));
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
|
||||
assert.match(
|
||||
security,
|
||||
/미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보, 학교 급식 식단은 이 값이 없으면 기본 hosted path\(`k-skill-proxy\.nomadamas\.org`\)를 사용한다\./,
|
||||
);
|
||||
assert.match(
|
||||
install,
|
||||
/`k-schoollunch-menu` 는 별도 설치 없이 `k-skill-proxy`의 `\/v1\/neis\/school-search`, `\/v1\/neis\/school-meal` 라우트를 호출하고, `KEDU_INFO_KEY`는 proxy 서버에서만 나이스 Open API `KEY`로 주입한다\. 사용자 쪽 `KEDU_INFO_KEY` 가 불필요하다\./,
|
||||
);
|
||||
});
|
||||
|
||||
test("repository docs advertise the used-car-price-search skill", () => {
|
||||
const readme = read("README.md");
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ while IFS= read -r -d '' skill_dir; do
|
|||
fi
|
||||
done < <(
|
||||
find "$root" -mindepth 1 -maxdepth 1 -type d \
|
||||
! -name '.*' \
|
||||
! -name .git \
|
||||
! -name .github \
|
||||
! -name .claude \
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue