NEIS 급식·학교검색 프록시, k-schoollunch-menu 스킬 및 문서

- KEDU_INFO_KEY로 /v1/neis/school-search, /v1/neis/school-meal 중계
- 시도교육청 자연어 해석(neis-office-codes.js)
- k-schoollunch-menu 스킬, README·설치/설정/보안·프록시 문서 반영
- docs/adding-a-skill.md 스킬 추가 가이드

Made-with: Cursor
This commit is contained in:
hyeongr 2026-04-10 13:14:15 +09:00
commit fb9a5c6f0d
13 changed files with 1236 additions and 3 deletions

View file

@ -27,6 +27,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 한국 법령 검색 | `korean-law-mcp` 우선 + 장애 시 `법망` fallback으로 법령/조문/판례/유권해석 조회 | 로컬 CLI/MCP면 `LAW_OC` 필요, remote endpoint/법망 fallback은 불필요 | [한국 법령 검색 가이드](docs/features/korean-law-search.md) |
| 한국 부동산 실거래가 조회 | upstream `real-estate-mcp`로 아파트/오피스텔/빌라/단독주택 실거래가·전월세·지역코드 조회, hosted endpoint가 없으면 self-host + Cloudflare Tunnel + launchd 운영 | 로컬/stdio/self-host면 `DATA_GO_KR_API_KEY` 필요 | [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md) |
| 생활쓰레기 배출정보 조회 | 행정안전부 생활쓰레기배출정보 원본 endpoint 스펙으로 조회하고 `serviceKey`는 proxy 서버에서 주입해 시군구 기준 배출장소/배출방법/배출요일·시간 안내 | 불필요 (proxy 서버에서 `DATA_GO_KR_API_KEY` 주입) | [생활쓰레기 배출정보 조회 가이드](docs/features/household-waste-info.md) |
| 학교 급식 식단 조회 | NEIS 학교기본정보·급식식단을 `k-skill-proxy``school-search``school-meal`로 조회해 자연어 교육청·학교명 기준 메뉴 안내 | 불필요 (proxy 서버에서 `KEDU_INFO_KEY` 주입) | [학교 급식 식단 조회 가이드](docs/features/k-schoollunch-menu.md) |
| 조선왕조실록 검색 | 공식 조선왕조실록 사이트에서 키워드 검색 후 왕별/연도별 필터와 기사 excerpt 조회 | 불필요 | [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md) |
| 근처 가장 싼 주유소 찾기 | 현재 위치를 먼저 확인한 뒤 Kakao Map anchor + Opinet 공식 API로 근처 최저가 주유소 조회 | `OPINET_API_KEY` 필요 | [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md) |
| KBO 경기 결과 조회 | 날짜별 경기 일정, 결과, 팀별 필터링 | 불필요 | [KBO 결과 가이드](docs/features/kbo-results.md) |
@ -77,6 +78,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [한국 법령 검색 가이드](docs/features/korean-law-search.md)
- [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md)
- [생활쓰레기 배출정보 조회 가이드](docs/features/household-waste-info.md)
- [학교 급식 식단 조회 가이드](docs/features/k-schoollunch-menu.md)
- [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md)
- [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md)
- [KBO 경기 결과 조회](docs/features/kbo-results.md)

216
docs/adding-a-skill.md Normal file
View file

@ -0,0 +1,216 @@
# 새 스킬 추가 가이드
새 스킬을 k-skill에 추가하는 방법과 스킬이 동작하는 구조를 설명한다.
---
## 스킬이란
스킬은 AI 에이전트(Claude Code 등)가 특정 작업을 수행하는 방법을 정의한 문서+코드 묶음이다. 에이전트는 `SKILL.md`를 읽고 거기 적힌 워크플로우를 따라 실행한다.
스킬에는 네 가지 구현 유형이 있다.
| 유형 | 설명 | 예시 |
|------|------|------|
| **SKILL.md 전용** | 문서만으로 동작 (에이전트가 bash/python 직접 실행) | `kakaotalk-mac`, `srt-booking` |
| **npm 패키지** | `packages/` 아래 Node.js 라이브러리로 구현 | `k-lotto`, `blue-ribbon-nearby` |
| **프록시 경유** | `k-skill-proxy`가 upstream API 키를 보관하고 HTTP로 중계 | `seoul-subway-arrival`, `fine-dust-location` |
| **Python 스크립트** | `scripts/`의 Python 파일 직접 실행 | `korean-spell-check`, `sillok-search` |
---
## 스킬의 구조
모든 스킬은 **저장소 루트에 디렉토리 하나**를 갖는다.
```
k-skill/
├── my-new-skill/ ← 스킬 디렉토리 (이름 = 스킬 이름)
│ ├── SKILL.md ← 필수. 에이전트가 읽는 핵심 파일
│ └── (지원 파일들) ← 선택. 스크립트, 데이터 등
├── packages/ ← npm 패키지 유형일 때만
│ └── my-new-skill/
│ ├── package.json
│ ├── src/
│ └── test/
└── scripts/ ← Python 스크립트 유형일 때만
└── my_new_skill.py
```
---
## SKILL.md 형식
`SKILL.md`는 YAML frontmatter + Markdown 본문으로 구성된다.
```markdown
---
name: my-new-skill
description: 한 문장으로 이 스킬이 무엇을 하는지 설명한다. 에이전트 UI에 표시된다.
license: MIT
metadata:
category: utility
locale: ko-KR
phase: v1
---
# My New Skill
## What this skill does
이 스킬이 무엇을 하는지 한두 문단으로 설명한다.
## When to use
- "사용자가 이런 말을 할 때"
- "또는 이런 상황일 때"
## Prerequisites
- Node.js 18+ (필요하면)
- 패키지 설치 명령
## Workflow
### 1. 첫 번째 단계
설명과 실행할 코드를 적는다.
```bash
# 실행할 명령어
```
### 2. 두 번째 단계
...
## Done when
- 이런 조건이 만족되면 완료다
## Failure modes
- 예상 가능한 실패 상황
## Notes
- 특이사항, 보안 정책 등
```
### frontmatter 필드
| 필드 | 필수 | 설명 |
|------|------|------|
| `name` | ✅ | **디렉토리 이름과 정확히 일치**해야 한다 |
| `description` | ✅ | 에이전트 UI 표시용 한 줄 설명 |
| `license` | ✅ | 항상 `MIT` |
| `metadata.category` | ✅ | `utility` / `transit` / `travel` / `messaging` / `legal` / `setup` 등 |
| `metadata.locale` | ✅ | `ko-KR` |
| `metadata.phase` | ✅ | `v1` (안정) / `v1.5` (기능 추가 중) |
---
## 유형별 구현 방법
### A. SKILL.md 전용 스킬
에이전트가 `SKILL.md` 안의 bash/python 코드를 직접 실행한다.
1. 디렉토리 생성: `mkdir my-new-skill`
2. `my-new-skill/SKILL.md` 작성
3. Workflow 섹션에 에이전트가 따를 단계별 명령어를 적는다
외부 라이브러리나 서버 없이 동작해야 한다.
### B. npm 패키지 스킬
`packages/my-new-skill/`에 Node.js 구현체를 만들고, 루트 디렉토리 `my-new-skill/SKILL.md`에서 `require('my-new-skill')`로 호출한다.
```
packages/my-new-skill/
├── package.json # name, version, main, exports 필수
├── README.md
├── src/
│ └── index.js
└── test/
└── index.test.js
```
`package.json``"name": "my-new-skill"` 설정 후 루트 `package.json``workspaces`에 등록한다.
npm에 배포하려면 `.changeset/` 파일을 추가한다 (`docs/releasing.md` 참고).
### C. 프록시 경유 스킬
upstream API 키를 사용자에게 노출하지 않으려면 `k-skill-proxy`를 경유한다.
1. `packages/k-skill-proxy/src/server.js`에 새 route 추가
2. `SKILL.md` Workflow에 `curl $KSKILL_PROXY_BASE_URL/v1/...` 형태로 호출 작성
3. upstream API 키는 서버의 `~/.config/k-skill/secrets.env`에만 보관
프록시 서버는 main 브랜치에 merge되어야 프로덕션에 반영된다 (`CLAUDE.md` 참고).
### D. Python 스크립트 스킬
`scripts/my_skill.py`를 만들고 `SKILL.md`에서 `python3 scripts/my_skill.py`로 호출한다.
---
## 스킬 등록 & 검증
스킬은 **별도 레지스트리 없이 디렉토리 스캔으로 자동 발견**된다.
추가 후 검증:
```bash
npm run ci
```
이 명령은 `scripts/validate-skills.sh`를 실행해 다음을 확인한다.
- 루트 하위 모든 디렉토리에 `SKILL.md`가 있는지
- frontmatter가 `---`로 시작하는지
- `name` 필드가 있는지
- `description` 필드가 있는지
- `name` 필드 값이 디렉토리 이름과 일치하는지
---
## 시크릿이 필요한 스킬
인증이 필요한 스킬은 아래 우선순위로 credential을 확보한다.
1. 이미 환경변수에 있으면 → 그대로 사용
2. 에이전트 vault(1Password, Bitwarden, macOS Keychain) → 주입
3. `~/.config/k-skill/secrets.env` → 파일에서 읽기
4. 아무것도 없으면 → 사용자에게 물어보고 3번에 저장
시크릿 변수 이름 규칙: `KSKILL_<서비스명>_<항목>` (예: `KSKILL_SRT_ID`)
절대 하지 말 것:
- 시크릿을 저장소에 커밋
- 프록시 upstream 키를 클라이언트에 노출
- 사용자 확인 없이 side-effect가 있는 작업 실행
---
## 체크리스트
새 스킬을 PR 올리기 전에 확인한다.
- [ ] `my-new-skill/SKILL.md` 작성 완료
- [ ] frontmatter `name`이 디렉토리 이름과 일치
- [ ] `npm run ci` 통과 (`./scripts/validate-skills.sh` 포함)
- [ ] npm 패키지라면 `packages/`에 구현체와 테스트 추가
- [ ] npm 패키지라면 `.changeset/*.md` 파일 추가 (반드시 **기능 PR에서**, Version Packages PR에서 추가하지 말 것)
- [ ] 프록시 경유라면 `k-skill-proxy/src/server.js`에 route 추가하고 main에 merge
- [ ] 시크릿이 있다면 `KSKILL_` 접두사 규칙 준수 및 `docs/setup.md` 업데이트
- [ ] `docs/features/my-new-skill.md` 작성 (선택, 상세 가이드)
---
## 관련 문서
- [공통 설정 가이드](setup.md) — 시크릿 설정 방법
- [릴리스와 자동 배포](releasing.md) — npm 패키지 배포 흐름
- [보안/시크릿 정책](security-and-secrets.md) — 인증 정보 취급 원칙

View file

@ -0,0 +1,61 @@
# 학교 급식 식단 조회 가이드
## 이 기능으로 할 수 있는 일
- 시도교육청 이름(자연어) + 학교 이름으로 학교 코드 조회
- 특정 일자 급식 식단(조·중·석) 조회
- 나이스(NEIS) Open API 인증키는 프록시 서버(`KEDU_INFO_KEY`)에서만 관리
## 가장 중요한 규칙
1. 클라이언트는 **`KEDU_INFO_KEY`를 들고 있지 않는다.** `k-skill-proxy`만 upstream `KEY`를 붙인다.
2. 학교 식별은 **하드코딩 금지**. 반드시 **`/v1/neis/school-search``/v1/neis/school-meal`** 순서로 조합한다.
## 먼저 필요한 것
- 인터넷 연결
- 프록시 base URL (기본: `https://k-skill-proxy.nomadamas.org`)
## 기본 조회 흐름
### 1) 학교 검색
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/neis/school-search' \
--data-urlencode 'educationOffice=서울특별시교육청' \
--data-urlencode 'schoolName=미래초등학교'
```
응답에 `resolved_education_office``schoolInfo` 블록이 붙는다. `row`에서 `ATPT_OFCDC_SC_CODE`, `SD_SCHUL_CODE`, `SCHUL_NM`, 주소 필드를 확인한다.
### 2) 급식 조회
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/neis/school-meal' \
--data-urlencode 'educationOfficeCode=B10' \
--data-urlencode 'schoolCode=7010123' \
--data-urlencode 'mealDate=20260410'
```
`educationOfficeCode` / `schoolCode`는 1단계 검색 결과에서 가져온다.
선택: `mealKindCode=1` (조식), `2` (중식), `3` (석식).
## 파라미터 요약
| 단계 | 주요 쿼리 |
| --- | --- |
| school-search | `educationOffice`, `schoolName` (별칭: `office`, `school`, …) |
| school-meal | `educationOfficeCode`, `schoolCode`, `mealDate` (`YYYYMMDD` 또는 `YYYY-MM-DD`) |
## 자주 보는 필드 (급식)
- `MLSV_YMD`: 급식일
- `MMEAL_SC_NM` / `MMEAL_SC_CODE`: 끼니 구분
- `DDISH_NM`: 메뉴(HTML `<br/>` 구분이 많음)
- `CAL_INFO`, `NTR_INFO`: 칼로리·영양 정보(있는 경우)
## 참고 링크
- 나이스 교육정보 개방 포털: `https://open.neis.go.kr/`
- 프록시 구현·엔드포인트 목록: [k-skill 프록시 서버 가이드](k-skill-proxy.md)

View file

@ -20,6 +20,8 @@ client/skill -> k-skill-proxy -> upstream public API
- `GET /v1/han-river/water-level`
- `GET /v1/opinet/around`
- `GET /v1/opinet/detail`
- `GET /v1/neis/school-search` (나이스 학교기본정보, `KEDU_INFO_KEY`)
- `GET /v1/neis/school-meal` (나이스 급식식단정보, `KEDU_INFO_KEY`)
- `GET /B552584/:service/:operation` (허용된 AirKorea route passthrough)
## 권장 환경변수
@ -34,6 +36,7 @@ client/skill -> k-skill-proxy -> upstream public API
- `SEOUL_OPEN_API_KEY=...`
- `HRFCO_OPEN_API_KEY=...`
- `OPINET_API_KEY=...`
- `KEDU_INFO_KEY=...` (나이스 교육정보 개방 포털 Open API 인증키)
- `KSKILL_PROXY_PORT=4020`
## 프로덕션 배포 구조
@ -122,6 +125,21 @@ 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/neis/school-search' \
--data-urlencode 'educationOffice=서울특별시교육청' \
--data-urlencode 'schoolName=미래초등학교'
```
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/neis/school-meal' \
--data-urlencode 'educationOfficeCode=B10' \
--data-urlencode 'schoolCode=7010123' \
--data-urlencode 'mealDate=20260410'
```
AirKorea passthrough endpoint:
```bash

View file

@ -66,7 +66,8 @@ npx --yes skills add <owner/repo> \
--skill delivery-tracking \
--skill coupang-product-search \
--skill used-car-price-search \
--skill korean-spell-check
--skill korean-spell-check \
--skill k-schoollunch-menu
```
인증이 필요한 기능만 부분 설치할 때도 `k-skill-setup` 은 같이 넣는다.
@ -221,6 +222,7 @@ python3 scripts/korean_spell_check.py --text "아버지가방에들어가신다.
- `real-estate-search`
- `household-waste-info`
- `cheap-gas-nearby`
- `k-schoollunch-menu` (hosted proxy에 `KEDU_INFO_KEY`가 배포된 경우 사용자 시크릿 불필요)
관련 문서:

View file

@ -64,6 +64,6 @@ KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
- `AIR_KOREA_OPEN_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`)를 경유하고, 생활쓰레기 배출정보 조회는 원본 API endpoint를 쓰되 `serviceKey`를 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다. 근처 가장 싼 주유소 찾기는 기본 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` 를 사용할 수 있다. 다만 일반 사용자/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`)를 경유하고, 생활쓰레기 배출정보 조회는 원본 API endpoint를 쓰되 `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

@ -69,6 +69,7 @@ bash scripts/check-setup.sh
| 서울 지하철 도착정보 조회 | self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL` |
| 사용자 위치 미세먼지 조회 | `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY` |
| 한강 수위 정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
| 학교 급식 식단 조회 | 사용자 시크릿 불필요 (프록시에 `KEDU_INFO_KEY`가 설정된 hosted/self-host 사용) |
## 다음에 볼 문서
@ -80,6 +81,7 @@ bash scripts/check-setup.sh
- [한국 법령 검색 가이드](features/korean-law-search.md)
- [한국 부동산 실거래가 조회 가이드](features/real-estate-search.md)
- [근처 가장 싼 주유소 찾기 가이드](features/cheap-gas-nearby.md)
- [학교 급식 식단 조회 가이드](features/k-schoollunch-menu.md)
- [보안/시크릿 정책](security-and-secrets.md)
설치 기본 흐름은 "전체 스킬 설치 → 개별 기능 사용" 이다.

View file

@ -4,4 +4,5 @@ KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
LAW_OC=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
KEDU_INFO_KEY=replace-me
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com

119
k-schoollunch-menu/SKILL.md Normal file
View file

@ -0,0 +1,119 @@
---
name: k-schoollunch-menu
description: Use when the user asks for Korean school meal menus (급식 식단) by natural-language education office and school name, via k-skill-proxy NEIS school-search and school-meal routes.
license: MIT
metadata:
category: utility
locale: ko-KR
phase: v1
---
# Korean School Lunch Menu (NEIS)
## What this skill does
나이스(NEIS) 교육정보 개방 포털의 **학교기본정보**·**급식식단정보**를 `k-skill-proxy`가 중계하는 HTTP API로 조회한다.
- 사용자는 **시도교육청 이름**(자연어)과 **학교 이름**, **날짜**만 말하면 된다.
- 에이전트는 먼저 `/v1/neis/school-search`로 학교를 찾고, 응답의 `SD_SCHUL_CODE`·`ATPT_OFCDC_SC_CODE``/v1/neis/school-meal`을 호출한다.
- 인증키(`KEDU_INFO_KEY`)는 **프록시 서버에만** 두고, 클라이언트는 키 없이 프록시 URL만 호출한다.
## When to use
- "서울특별시교육청 미래초등학교 오늘 급식 뭐야?"
- "○○초 급식 식단 알려줘"
- "이번 주 화요일 중학교 급식 메뉴"
- "급식 메뉴 조회해줘" (교육청·학교·날짜 확인 후 진행)
## Prerequisites
- 인터넷 연결
- `curl` 사용 가능 환경
- `k-skill-proxy``KEDU_INFO_KEY`가 설정된 배포(기본 hosted 또는 self-host)에 접근 가능할 것
## Credential requirements
- 사용자 측 **필수** 시크릿 없음.
- `KSKILL_PROXY_BASE_URL` — self-host·별도 프록시를 쓸 때만 설정. 비우면 기본 hosted `https://k-skill-proxy.nomadamas.org` 를 사용한다.
- `KEDU_INFO_KEY`**프록시 운영 서버** 환경에만 둔다.
## Proxy base URL
에이전트는 아래처럼 base 를 정한다.
```bash
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
BASE="${BASE%/}"
```
## Workflow
### 1) Collect inputs (do not guess)
다음이 없으면 사용자에게 짧게 묻는다.
1. **교육청** — 자연어 허용 (예: `서울특별시교육청`, `서울`, `경기도교육청`).
2. **학교명** — 자연어 (예: `미래초등학교`, `○○중학교`).
3. **급식일**`YYYYMMDD` 또는 사용자가 말한 날짜를 한국 시간 기준으로 `YYYYMMDD`로 정한다. 생략 시 **오늘(한국 시간)**.
교육청 표현이 애매해 `ambiguous_education_office`가 나오면, 응답의 `candidate_codes`를 보여 주고 더 구체적인 이름(예: `경상북도교육청` vs `경상남도교육청`)을 받는다.
### 2) Search school (`/v1/neis/school-search`)
```bash
curl -fsS --get "${BASE}/v1/neis/school-search" \
--data-urlencode "educationOffice=${EDU_OFFICE}" \
--data-urlencode "schoolName=${SCHOOL_NAME}"
```
- `EDU_OFFICE`, `SCHOOL_NAME`은 사용자 입력을 그대로 넣어도 된다. 프록시가 교육청명을 코드로 해석한다.
- 응답의 `resolved_education_office.atpt_ofcdc_sc_code`로 실제 매칭된 시도교육청 코드를 확인할 수 있다.
### 3) Disambiguate when multiple schools match
`schoolInfo` 본문에서 `row`가 **여러 개**면 사용자에게 **학교명·주소(`ORG_RDNMA` 등)**를 보여 주고 하나를 고르게 한다.
한 건뿐이면 그 row의 `ATPT_OFCDC_SC_CODE`, `SD_SCHUL_CODE`를 다음 단계에 쓴다.
### 4) Fetch meal (`/v1/neis/school-meal`)
```bash
curl -fsS --get "${BASE}/v1/neis/school-meal" \
--data-urlencode "educationOfficeCode=${ATPT}" \
--data-urlencode "schoolCode=${SD}" \
--data-urlencode "mealDate=${YYYYMMDD}"
```
- `ATPT` / `SD`는 3단계에서 확정한 코드.
- **조식·중식·석식만** 보고 싶으면 `mealKindCode=1|2|3` (선택).
### 5) Summarize for the user
- `mealServiceDietInfo` 안의 `row`를 기준으로 요약한다.
- 메뉴 문자열(`DDISH_NM` 등)의 `<br/>`는 줄바꿈으로 바꿔 읽기 쉽게 한다.
- 칼로리·영양 정보 필드가 있으면 한두 줄로 덧붙인다.
- NEIS가 빈 결과를 주면 "해당 일자 급식 데이터 없음" 가능성을 안내한다.
## Upstream reference
- 급식·학교기본정보 데이터: [나이스 교육정보 개방 포털](https://open.neis.go.kr/)
## Done when
- 교육청·학교·날짜를 확인했다.
- 학교 검색으로 단일 학교를 확정했다(또는 사용자가 선택했다).
- 급식 API 호출에 성공했고, 메뉴를 사용자 친화적으로 정리했다.
## Failure modes
- 프록시에 `KEDU_INFO_KEY` 미설정 → `503` / `upstream_not_configured`
- 교육청 이름이 여러 시도에 걸침 → `400` / `ambiguous_education_office`
- 학교명이 여러 건 — 사용자 선택 없이 임의로 고르지 말 것
- 공휴일·방학·미제공 일자로 빈 급식
- NEIS API 일시 장애·호출 제한
## Notes
- 학교 코드를 사용자에게 외우게 하지 않는다. 항상 `school-search``school-meal` 순서를 따른다.
- Raw JSON을 그대로 붙여 넣지 말고 요약 위주로 답한다.
- 자세한 엔드포인트·필드는 `docs/features/k-schoollunch-menu.md``docs/features/k-skill-proxy.md`를 참고한다.

View file

@ -8,12 +8,15 @@
- `GET /v1/fine-dust/report`
- `GET /v1/seoul-subway/arrival`
- `GET /v1/han-river/water-level`
- `GET /v1/neis/school-search` — 나이스 학교기본정보(교육청명·학교명 검색)
- `GET /v1/neis/school-meal` — 나이스 급식식단정보(일자별 메뉴)
## 환경변수
- `AIR_KOREA_OPEN_API_KEY` — 프록시 서버 쪽 AirKorea upstream key
- `SEOUL_OPEN_API_KEY` — 프록시 서버 쪽 서울 열린데이터 광장 upstream key
- `HRFCO_OPEN_API_KEY` — 프록시 서버 쪽 한강홍수통제소 upstream key
- `KEDU_INFO_KEY` — 프록시 서버 쪽 나이스(NEIS) 교육정보 개방 포털 Open API 인증키 (`school-search`, `school-meal`)
- `KSKILL_PROXY_HOST` — 기본 `127.0.0.1`
- `KSKILL_PROXY_PORT` — 기본 `4020`
- `KSKILL_PROXY_CACHE_TTL_MS` — 기본 `300000`

View file

@ -0,0 +1,174 @@
/**
* 시도교육청 코드(ATPT_OFCDC_SC_CODE) 나이스 교육정보 개방 포털 학교기본정보·급식 등과 동일 체계.
* 자연어 별칭은 사용자가 흔히 쓰는 교육청명·약칭을 포함한다.
*/
const OFFICES = [
{
code: "B10",
labels: [
"서울특별시교육청",
"서울시교육청",
"서울교육청",
"서울특별시",
"서울"
]
},
{
code: "C10",
labels: ["부산광역시교육청", "부산시교육청", "부산교육청", "부산광역시", "부산"]
},
{
code: "D10",
labels: ["대구광역시교육청", "대구시교육청", "대구교육청", "대구광역시", "대구"]
},
{
code: "E10",
labels: ["인천광역시교육청", "인천시교육청", "인천교육청", "인천광역시", "인천"]
},
{
code: "F10",
labels: ["광주광역시교육청", "광주시교육청", "광주교육청", "광주광역시", "광주"]
},
{
code: "G10",
labels: ["대전광역시교육청", "대전시교육청", "대전교육청", "대전광역시", "대전"]
},
{
code: "H10",
labels: ["울산광역시교육청", "울산시교육청", "울산교육청", "울산광역시", "울산"]
},
{
code: "I10",
labels: ["세종특별자치시교육청", "세종교육청", "세종특별자치시", "세종"]
},
{
code: "J10",
labels: ["경기도교육청", "경기교육청", "경기도", "경기"]
},
{
code: "K10",
labels: [
"강원특별자치도교육청",
"강원도교육청",
"강원교육청",
"강원특별자치도",
"강원도",
"강원"
]
},
{
code: "M10",
labels: ["충청북도교육청", "충북교육청", "충청북도", "충북"]
},
{
code: "N10",
labels: ["충청남도교육청", "충남교육청", "충청남도", "충남"]
},
{
code: "P10",
labels: [
"전북특별자치도교육청",
"전라북도교육청",
"전북교육청",
"전북특별자치도",
"전라북도",
"전북"
]
},
{
code: "Q10",
labels: ["전라남도교육청", "전남교육청", "전라남도", "전남"]
},
{
code: "R10",
labels: ["경상북도교육청", "경북교육청", "경상북도", "경북"]
},
{
code: "S10",
labels: ["경상남도교육청", "경남교육청", "경상남도", "경남"]
},
{
code: "T10",
labels: ["제주특별자치도교육청", "제주교육청", "제주특별자치도", "제주"]
}
];
const KNOWN_CODES = new Set(OFFICES.map((o) => o.code));
function compactKo(value) {
return String(value).trim().replace(/\s+/g, "");
}
/**
* @param {string | null | undefined} rawHint
* @returns {{ ok: true, code: string, matchedLabel: string } | { ok: false, reason: "unknown" } | { ok: false, reason: "ambiguous", codes: string[], hint: string }}
*/
function resolveEducationOfficeFromNaturalLanguage(rawHint) {
const trimmed = rawHint === undefined || rawHint === null ? "" : String(rawHint).trim();
if (!trimmed) {
return { ok: false, reason: "unknown" };
}
const codeLike = trimmed.match(/^\s*([A-Za-z])(\d{2})\s*$/);
if (codeLike) {
const code = `${codeLike[1].toUpperCase()}${codeLike[2]}`;
if (KNOWN_CODES.has(code)) {
return { ok: true, code, matchedLabel: code };
}
return { ok: false, reason: "unknown" };
}
const h = compactKo(trimmed);
if (h.length < 2) {
return { ok: false, reason: "unknown" };
}
/** @type {{ code: string, label: string, score: number }[]} */
const hits = [];
for (const office of OFFICES) {
for (const label of office.labels) {
const l = compactKo(label);
if (!l) {
continue;
}
let score = 0;
if (h === l) {
score = 1000 + l.length;
} else if (l.startsWith(h) || h.startsWith(l)) {
score = 500 + Math.min(l.length, h.length);
} else if (l.includes(h)) {
score = 200 + h.length;
} else if (h.includes(l) && l.length >= 4) {
score = 100 + l.length;
}
if (score > 0) {
hits.push({ code: office.code, label, score });
}
}
}
if (hits.length === 0) {
return { ok: false, reason: "unknown" };
}
hits.sort((a, b) => b.score - a.score);
const top = hits[0].score;
const topCodes = [...new Set(hits.filter((x) => x.score === top).map((x) => x.code))];
if (topCodes.length > 1) {
return { ok: false, reason: "ambiguous", codes: topCodes, hint: trimmed };
}
const winner = hits.find((x) => x.score === top && x.code === topCodes[0]);
return { ok: true, code: topCodes[0], matchedLabel: winner.label };
}
module.exports = {
OFFICES,
KNOWN_CODES,
resolveEducationOfficeFromNaturalLanguage
};

View file

@ -5,9 +5,12 @@ const { proxyBlueRibbonNearbyRequest } = require("./bluer");
const { fetchWaterLevelReport } = require("./hrfco");
const { fetchTransactions, VALID_ASSET_TYPES, VALID_DEAL_TYPES } = require("./molit");
const { searchRegionCode } = require("./region-lookup");
const { resolveEducationOfficeFromNaturalLanguage } = require("./neis-office-codes");
const AIR_KOREA_UPSTREAM_BASE_URL = "http://apis.data.go.kr";
const SEOUL_OPEN_API_BASE_URL = "http://swopenapi.seoul.go.kr";
const OPINET_API_BASE_URL = "https://www.opinet.co.kr/api";
const NEIS_MEAL_SERVICE_URL = "https://open.neis.go.kr/hub/mealServiceDietInfo";
const NEIS_SCHOOL_INFO_URL = "https://open.neis.go.kr/hub/schoolInfo";
const ALLOWED_AIRKOREA_ROUTES = new Map([
["MsrstnInfoInqireSvc", new Set(["getMsrstnList", "getNearbyMsrstnList", "getTMStdrCrdnt"])],
["ArpltnInforInqireSvc", new Set(["getMsrstnAcctoRltmMesureDnsty", "getCtprvnRltmMesureDnsty"])],
@ -51,6 +54,7 @@ function buildConfig(env = process.env) {
opinetApiKey: trimOrNull(env.OPINET_API_KEY),
blueRibbonSessionId: trimOrNull(env.BLUE_RIBBON_SESSION_ID),
molitApiKey: trimOrNull(env.DATA_GO_KR_API_KEY),
keduInfoKey: trimOrNull(env.KEDU_INFO_KEY),
cacheTtlMs: parseInteger(env.KSKILL_PROXY_CACHE_TTL_MS, 300000),
rateLimitWindowMs: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_WINDOW_MS, 60000),
rateLimitMax: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_MAX, 60)
@ -174,6 +178,111 @@ function normalizeOpinetDetailQuery(query) {
return { id };
}
function normalizeNeisSchoolMealQuery(query) {
const atptOfcdcScCode = trimOrNull(
query.atptOfcdcScCode ??
query.ATPT_OFCDC_SC_CODE ??
query.education_office_code ??
query.educationOfficeCode
);
const sdSchulCode = trimOrNull(
query.sdSchulCode ?? query.SD_SCHUL_CODE ?? query.school_code ?? query.schoolCode
);
const dateRaw = trimOrNull(
query.mlsvYmd ?? query.MLSV_YMD ?? query.meal_date ?? query.mealDate ?? query.date
);
if (!atptOfcdcScCode) {
throw new Error("Provide educationOfficeCode (ATPT_OFCDC_SC_CODE).");
}
if (!sdSchulCode) {
throw new Error("Provide schoolCode (SD_SCHUL_CODE).");
}
if (!dateRaw) {
throw new Error("Provide mealDate (MLSV_YMD) as YYYYMMDD.");
}
const mlsvYmd = dateRaw.replaceAll("-", "").replaceAll(".", "");
if (!/^\d{8}$/.test(mlsvYmd)) {
throw new Error("mealDate must be YYYYMMDD (8 digits).");
}
const mealKindRaw = trimOrNull(
query.mmealScCode ?? query.MMEAL_SC_CODE ?? query.meal_kind_code ?? query.mealKindCode
);
let mmealScCode = null;
if (mealKindRaw) {
if (!["1", "2", "3"].includes(mealKindRaw)) {
throw new Error("mealKindCode must be 1 (breakfast), 2 (lunch), or 3 (dinner).");
}
mmealScCode = mealKindRaw;
}
const pIndex = parseInteger(query.pIndex ?? query.p_index, 1);
const pSize = parseInteger(query.pSize ?? query.p_size, 100);
if (pIndex < 1) {
throw new Error("pIndex must be >= 1.");
}
if (pSize < 1 || pSize > 1000) {
throw new Error("pSize must be between 1 and 1000.");
}
return { atptOfcdcScCode, sdSchulCode, mlsvYmd, mmealScCode, pIndex, pSize };
}
function normalizeNeisSchoolSearchQuery(query) {
const educationOfficeRaw = trimOrNull(
query.educationOffice ??
query.education_office ??
query.office ??
query.atpt ??
query.ATPT_OFCDC_SC_CODE
);
const schoolNameRaw = trimOrNull(
query.schoolName ?? query.school_name ?? query.school ?? query.SCHUL_NM ?? query.schulNm
);
if (!educationOfficeRaw) {
throw new Error("Provide educationOffice (e.g. 서울특별시교육청 or B10).");
}
if (!schoolNameRaw) {
throw new Error("Provide schoolName (e.g. 미래초등학교).");
}
const resolved = resolveEducationOfficeFromNaturalLanguage(educationOfficeRaw);
if (!resolved.ok) {
if (resolved.reason === "ambiguous") {
const err = new Error(
`educationOffice matched multiple offices (${resolved.codes.join(", ")}). Use a more specific name or pass the ATPT code (e.g. B10).`
);
err.code = "ambiguous_education_office";
err.candidate_codes = resolved.codes;
throw err;
}
throw new Error(
"educationOffice is not a recognized regional office. Use names like 서울특별시교육청 or a code like B10."
);
}
const pIndex = parseInteger(query.pIndex ?? query.p_index, 1);
const pSize = parseInteger(query.pSize ?? query.p_size, 100);
if (pIndex < 1) {
throw new Error("pIndex must be >= 1.");
}
if (pSize < 1 || pSize > 1000) {
throw new Error("pSize must be between 1 and 1000.");
}
return {
educationOfficeInput: educationOfficeRaw,
atptOfcdcScCode: resolved.code,
resolvedOfficeLabel: resolved.matchedLabel,
schulNm: schoolNameRaw,
pIndex,
pSize
};
}
function normalizeBlueRibbonNearbyQuery(query) {
const latitude = parseFloatValue(query.latitude ?? query.lat);
const longitude = parseFloatValue(query.longitude ?? query.lng);
@ -401,6 +510,88 @@ async function proxyHrfcoWaterLevelRequest({
}
}
async function proxyNeisSchoolMealRequest({
apiKey,
atptOfcdcScCode,
sdSchulCode,
mlsvYmd,
mmealScCode = null,
pIndex = 1,
pSize = 100,
fetchImpl = global.fetch
}) {
if (!apiKey) {
return {
statusCode: 503,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "upstream_not_configured",
message: "KEDU_INFO_KEY is not configured on the proxy server."
})
};
}
const url = new URL(NEIS_MEAL_SERVICE_URL);
url.searchParams.set("KEY", apiKey);
url.searchParams.set("Type", "json");
url.searchParams.set("pIndex", String(pIndex));
url.searchParams.set("pSize", String(pSize));
url.searchParams.set("ATPT_OFCDC_SC_CODE", atptOfcdcScCode);
url.searchParams.set("SD_SCHUL_CODE", sdSchulCode);
url.searchParams.set("MLSV_YMD", mlsvYmd);
if (mmealScCode) {
url.searchParams.set("MMEAL_SC_CODE", mmealScCode);
}
const response = await fetchImpl(url, {
signal: AbortSignal.timeout(20000)
});
return {
statusCode: response.status,
contentType: response.headers.get("content-type") || "application/json; charset=utf-8",
body: await response.text()
};
}
async function proxyNeisSchoolInfoRequest({
apiKey,
atptOfcdcScCode,
schulNm,
pIndex = 1,
pSize = 100,
fetchImpl = global.fetch
}) {
if (!apiKey) {
return {
statusCode: 503,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "upstream_not_configured",
message: "KEDU_INFO_KEY is not configured on the proxy server."
})
};
}
const url = new URL(NEIS_SCHOOL_INFO_URL);
url.searchParams.set("KEY", apiKey);
url.searchParams.set("Type", "json");
url.searchParams.set("pIndex", String(pIndex));
url.searchParams.set("pSize", String(pSize));
url.searchParams.set("ATPT_OFCDC_SC_CODE", atptOfcdcScCode);
url.searchParams.set("SCHUL_NM", schulNm);
const response = await fetchImpl(url, {
signal: AbortSignal.timeout(20000)
});
return {
statusCode: response.status,
contentType: response.headers.get("content-type") || "application/json; charset=utf-8",
body: await response.text()
};
}
function buildServer({ env = process.env, provider = null } = {}) {
const config = buildConfig(env);
@ -437,7 +628,8 @@ function buildServer({ env = process.env, provider = null } = {}) {
seoulOpenApiConfigured: Boolean(config.seoulOpenApiKey),
hrfcoConfigured: Boolean(config.hrfcoApiKey),
opinetConfigured: Boolean(config.opinetApiKey),
molitConfigured: Boolean(config.molitApiKey)
molitConfigured: Boolean(config.molitApiKey),
neisSchoolMealConfigured: Boolean(config.keduInfoKey)
},
auth: {
tokenRequired: false
@ -1077,6 +1269,209 @@ function buildServer({ env = process.env, provider = null } = {}) {
return payload;
});
app.get("/v1/neis/school-search", async (request, reply) => {
let normalized;
try {
normalized = normalizeNeisSchoolSearchQuery(request.query || {});
} catch (error) {
reply.code(400);
const payload = {
error: error.code === "ambiguous_education_office" ? error.code : "bad_request",
message: error.message
};
if (Array.isArray(error.candidate_codes)) {
payload.candidate_codes = error.candidate_codes;
}
return payload;
}
const cacheKey = makeCacheKey({
route: "neis-school-search",
...normalized
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
if (!config.keduInfoKey) {
reply.code(503);
return {
error: "upstream_not_configured",
message: "KEDU_INFO_KEY is not configured on the proxy server.",
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
}
const upstream = await proxyNeisSchoolInfoRequest({
apiKey: config.keduInfoKey,
atptOfcdcScCode: normalized.atptOfcdcScCode,
schulNm: normalized.schulNm,
pIndex: normalized.pIndex,
pSize: normalized.pSize
});
reply.code(upstream.statusCode);
reply.header("content-type", upstream.contentType);
const looksJson =
upstream.contentType.includes("json") ||
upstream.body.trimStart().startsWith("{") ||
upstream.body.trimStart().startsWith("[");
if (!looksJson) {
return upstream.body;
}
let payload;
try {
payload = JSON.parse(upstream.body);
} catch {
return upstream.body;
}
payload.resolved_education_office = {
input: normalized.educationOfficeInput,
atpt_ofcdc_sc_code: normalized.atptOfcdcScCode,
matched_label: normalized.resolvedOfficeLabel
};
payload.proxy = {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
};
payload.query = {
education_office: normalized.educationOfficeInput,
school_name: normalized.schulNm,
p_index: normalized.pIndex,
p_size: normalized.pSize
};
if (upstream.statusCode >= 200 && upstream.statusCode < 300) {
cache.set(cacheKey, payload, config.cacheTtlMs);
}
return payload;
});
app.get("/v1/neis/school-meal", async (request, reply) => {
let normalized;
try {
normalized = normalizeNeisSchoolMealQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "neis-school-meal",
...normalized
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
if (!config.keduInfoKey) {
reply.code(503);
return {
error: "upstream_not_configured",
message: "KEDU_INFO_KEY is not configured on the proxy server.",
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
}
const upstream = await proxyNeisSchoolMealRequest({
apiKey: config.keduInfoKey,
atptOfcdcScCode: normalized.atptOfcdcScCode,
sdSchulCode: normalized.sdSchulCode,
mlsvYmd: normalized.mlsvYmd,
mmealScCode: normalized.mmealScCode,
pIndex: normalized.pIndex,
pSize: normalized.pSize
});
reply.code(upstream.statusCode);
reply.header("content-type", upstream.contentType);
const looksJson =
upstream.contentType.includes("json") ||
upstream.body.trimStart().startsWith("{") ||
upstream.body.trimStart().startsWith("[");
if (!looksJson) {
return upstream.body;
}
let payload;
try {
payload = JSON.parse(upstream.body);
} catch {
return upstream.body;
}
payload.proxy = {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
};
payload.query = {
education_office_code: normalized.atptOfcdcScCode,
school_code: normalized.sdSchulCode,
meal_date: normalized.mlsvYmd,
meal_kind_code: normalized.mmealScCode,
p_index: normalized.pIndex,
p_size: normalized.pSize
};
if (upstream.statusCode >= 200 && upstream.statusCode < 300) {
cache.set(cacheKey, payload, config.cacheTtlMs);
}
return payload;
});
app.setErrorHandler((error, request, reply) => {
request.log.error(error);
const statusCode = error.statusCode && error.statusCode >= 400 ? error.statusCode : 500;
@ -1089,6 +1484,10 @@ function buildServer({ env = process.env, provider = null } = {}) {
payload.candidate_stations = error.candidateStations;
}
if (Array.isArray(error.candidate_codes)) {
payload.candidate_codes = error.candidate_codes;
}
if (error.sidoName) {
payload.sido_name = error.sidoName;
}
@ -1121,11 +1520,15 @@ module.exports = {
normalizeHanRiverWaterLevelQuery,
normalizeOpinetAroundQuery,
normalizeOpinetDetailQuery,
normalizeNeisSchoolMealQuery,
normalizeNeisSchoolSearchQuery,
normalizeRealEstateQuery,
normalizeRegionCodeQuery,
normalizeSeoulSubwayQuery,
proxyAirKoreaRequest,
proxyHrfcoWaterLevelRequest,
proxyNeisSchoolMealRequest,
proxyNeisSchoolInfoRequest,
proxyOpinetRequest,
proxySeoulSubwayRequest,
startServer

View file

@ -7,6 +7,7 @@ const {
proxySeoulSubwayRequest,
proxyHrfcoWaterLevelRequest
} = require("../src/server");
const { resolveEducationOfficeFromNaturalLanguage } = require("../src/neis-office-codes");
test("health endpoint stays public and reports auth/upstream status", async (t) => {
const app = buildServer({
@ -791,3 +792,234 @@ test("health endpoint reports molitConfigured status", async (t) => {
assert.equal(response.json().upstreams.molitConfigured, true);
});
const SAMPLE_NEIS_MEAL_JSON = JSON.stringify({
mealServiceDietInfo: [
{
head: [{ LIST_TOTAL_COUNT: 1 }]
},
{
row: [
{
ATPT_OFCDC_SC_CODE: "J10",
SD_SCHUL_CODE: "1234567",
MLSV_YMD: "20260410",
MMEAL_SC_CODE: "2",
DDISH_NM: "밥<br/>국"
}
]
}
]
});
test("neis school-meal endpoint returns 503 without KEDU_INFO_KEY", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/neis/school-meal?educationOfficeCode=J10&schoolCode=1234567&mealDate=20260410"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
});
test("neis school-meal endpoint returns 400 when mealDate is invalid", async (t) => {
const app = buildServer({
env: { KEDU_INFO_KEY: "test-key" }
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/neis/school-meal?educationOfficeCode=J10&schoolCode=1234567&mealDate=2026041"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
});
test("neis school-meal endpoint proxies NEIS JSON and caches", async (t) => {
const originalFetch = global.fetch;
let fetchedUrl = "";
let fetchCalls = 0;
global.fetch = async (url) => {
fetchCalls += 1;
fetchedUrl = String(url);
return new Response(SAMPLE_NEIS_MEAL_JSON, {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
};
const app = buildServer({
env: {
KEDU_INFO_KEY: "neis-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const first = await app.inject({
method: "GET",
url: "/v1/neis/school-meal?educationOfficeCode=J10&schoolCode=1234567&mealDate=2026-04-10&mealKindCode=2"
});
const second = await app.inject({
method: "GET",
url: "/v1/neis/school-meal?educationOfficeCode=J10&schoolCode=1234567&mealDate=2026-04-10&mealKindCode=2"
});
assert.equal(first.statusCode, 200);
assert.equal(first.json().mealServiceDietInfo[1].row[0].DDISH_NM, "밥<br/>국");
assert.equal(first.json().query.meal_date, "20260410");
assert.equal(first.json().query.meal_kind_code, "2");
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(fetchCalls, 1);
assert.ok(fetchedUrl.includes("open.neis.go.kr/hub/mealServiceDietInfo"));
assert.ok(fetchedUrl.includes("KEY=neis-key"));
assert.ok(fetchedUrl.includes("ATPT_OFCDC_SC_CODE=J10"));
assert.ok(fetchedUrl.includes("SD_SCHUL_CODE=1234567"));
assert.ok(fetchedUrl.includes("MLSV_YMD=20260410"));
assert.ok(fetchedUrl.includes("MMEAL_SC_CODE=2"));
});
test("health endpoint reports neisSchoolMealConfigured when KEDU_INFO_KEY is set", async (t) => {
const app = buildServer({
env: { KEDU_INFO_KEY: "x" }
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/health"
});
assert.equal(response.json().upstreams.neisSchoolMealConfigured, true);
});
test("resolveEducationOfficeFromNaturalLanguage maps Seoul office phrases to B10", () => {
const a = resolveEducationOfficeFromNaturalLanguage("서울특별시교육청");
assert.equal(a.ok, true);
assert.equal(a.code, "B10");
const b = resolveEducationOfficeFromNaturalLanguage("B10");
assert.equal(b.ok, true);
assert.equal(b.code, "B10");
});
test("resolveEducationOfficeFromNaturalLanguage returns ambiguous for 경상", () => {
const r = resolveEducationOfficeFromNaturalLanguage("경상");
assert.equal(r.ok, false);
assert.equal(r.reason, "ambiguous");
});
const SAMPLE_NEIS_SCHOOL_JSON = JSON.stringify({
schoolInfo: [
{ head: [{ LIST_TOTAL_COUNT: 1 }] },
{
row: [
{
ATPT_OFCDC_SC_CODE: "B10",
SD_SCHUL_CODE: "7010123",
SCHUL_NM: "서울미래초등학교",
ORG_RDNMA: "서울특별시 …"
}
]
}
]
});
test("neis school-search returns 400 without schoolName", async (t) => {
const app = buildServer({ env: { KEDU_INFO_KEY: "k" } });
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: `/v1/neis/school-search?educationOffice=${encodeURIComponent("서울특별시교육청")}`
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
});
test("neis school-search returns ambiguous_education_office for 경상", async (t) => {
const app = buildServer({ env: { KEDU_INFO_KEY: "k" } });
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: `/v1/neis/school-search?educationOffice=${encodeURIComponent("경상")}&schoolName=${encodeURIComponent("중학교")}`
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "ambiguous_education_office");
assert.ok(Array.isArray(response.json().candidate_codes));
});
test("neis school-search proxies schoolInfo and resolves 교육청 이름", async (t) => {
const originalFetch = global.fetch;
let fetchedUrl = "";
let fetchCalls = 0;
global.fetch = async (url) => {
fetchCalls += 1;
fetchedUrl = String(url);
return new Response(SAMPLE_NEIS_SCHOOL_JSON, {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
};
const app = buildServer({
env: {
KEDU_INFO_KEY: "neis-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const edu = encodeURIComponent("서울특별시교육청");
const school = encodeURIComponent("미래초등학교");
const first = await app.inject({
method: "GET",
url: `/v1/neis/school-search?educationOffice=${edu}&schoolName=${school}`
});
const second = await app.inject({
method: "GET",
url: `/v1/neis/school-search?educationOffice=${edu}&schoolName=${school}`
});
assert.equal(first.statusCode, 200);
assert.equal(first.json().schoolInfo[1].row[0].SCHUL_NM, "서울미래초등학교");
assert.equal(first.json().resolved_education_office.atpt_ofcdc_sc_code, "B10");
assert.equal(first.json().resolved_education_office.input, "서울특별시교육청");
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(fetchCalls, 1);
assert.ok(fetchedUrl.includes("open.neis.go.kr/hub/schoolInfo"));
assert.ok(fetchedUrl.includes("ATPT_OFCDC_SC_CODE=B10"));
assert.ok(fetchedUrl.includes("SCHUL_NM"));
assert.ok(decodeURIComponent(fetchedUrl).includes("미래초등학교"));
});