mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
✨ 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:
parent
bcd32ac7cc
commit
fb9a5c6f0d
13 changed files with 1236 additions and 3 deletions
|
|
@ -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
216
docs/adding-a-skill.md
Normal 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) — 인증 정보 취급 원칙
|
||||
61
docs/features/k-schoollunch-menu.md
Normal file
61
docs/features/k-schoollunch-menu.md
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`가 배포된 경우 사용자 시크릿 불필요)
|
||||
|
||||
관련 문서:
|
||||
|
||||
|
|
|
|||
|
|
@ -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)를 본다.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
설치 기본 흐름은 "전체 스킬 설치 → 개별 기능 사용" 이다.
|
||||
|
|
|
|||
|
|
@ -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
119
k-schoollunch-menu/SKILL.md
Normal 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`를 참고한다.
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
174
packages/k-skill-proxy/src/neis-office-codes.js
Normal file
174
packages/k-skill-proxy/src/neis-office-codes.js
Normal 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
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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("미래초등학교"));
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue