🔀 Merge branch 'feature/#0' into feature/k-schoollunch-menu

NEIS 급식·생활쓰레기 프록시·문서·skill-docs 테스트 정렬 충돌을 해소했다.

Made-with: Cursor
This commit is contained in:
hyeongr 2026-04-11 14:42:31 +09:00
commit 0b37193fd6
12 changed files with 424 additions and 149 deletions

View file

@ -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::*]` 같은 부가 필터는 현재 지원하지 않는다 — 일반 사용자 질의("강남구 쓰레기 배출 요일")는 시군구 검색만으로 충분하기 때문이다.
## 조회 흐름 권장 순서

View file

@ -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

View file

@ -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

View file

@ -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)를 본다.

View file

@ -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)

View file

@ -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

View file

@ -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`

View file

@ -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 실행

View file

@ -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,

View file

@ -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 });

View file

@ -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"));

View file

@ -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 \