Route MFDS drug-safety and food-safety lookups through k-skill-proxy

Move direct API calls out of the skill Python scripts and into shared
proxy routes so end-users no longer need their own DATA_GO_KR_API_KEY
for MFDS surfaces. Adds mfds.js helper, proxy tests, and updates all
docs and setup guidance to reflect the hosted proxy workflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-14 00:07:18 +09:00
commit 2550d19974
21 changed files with 1063 additions and 390 deletions

View file

@ -1,26 +0,0 @@
# PRD: Issue 56 - Han River water-level proxy endpoint
## Goal
한강홍수통제소(HRFCO) Open API의 `waterlevel/info` + `waterlevel/list/10M``k-skill-proxy` 의 공개 read-only endpoint로 감싸서, 최종 사용자가 별도 ServiceKey 없이 한강 수위·유량을 조회할 수 있게 한다.
## User story
- 사용자는 "한강대교 지금 수위 어때?"처럼 관측소명 또는 관측소코드로 현재 수위와 유량을 빠르게 확인하고 싶다.
- 에이전트는 proxy가 주는 현재 관측값과 기준 수위를 요약해 답변한다.
## Scope
- `k-skill-proxy` 에 HRFCO waterlevel summary endpoint 추가
- proxy 환경변수/README/가이드 문서 반영
- 신규 han-river-water-level skill 및 기능 문서 추가
- 문서 회귀 테스트 + proxy server tests 추가
## Non-goals
- `rainfall` / `dam` / `bo` / `fldfct` 전체 확장
- private/auth-required proxy 도입
- 지도 기반 위치 추천 또는 관측소 선택 UX 고도화
## Acceptance criteria
1. proxy server 가 공개 read-only endpoint 로 HRFCO 현재 수위/유량을 요약 제공한다.
2. upstream HRFCO ServiceKey 는 proxy 서버 환경변수로만 관리된다.
3. endpoint 는 관측소명/관측소코드 기준 최신 관측시각, 수위, 유량, 기준수위를 포함한 JSON 을 반환한다.
4. 신규 skill/docs 는 hosted proxy 기본 경로와 무-key client workflow 를 문서화한다.
5. 로컬 테스트 및 최소 1회 실제 서버 실행/요청 검증을 완료한다.

View file

@ -1,11 +0,0 @@
# Test Spec: Issue 56 - Han River water-level proxy endpoint
## Regression coverage
1. `packages/k-skill-proxy/test/server.test.js` 에 HRFCO endpoint allowlist/serviceKey injection/public access/cache/ambiguous station assertions 추가
2. `scripts/skill-docs.test.js` 에 신규 han-river-water-level skill/docs/README/setup/sources/roadmap 노출면 검증 추가
3. root `npm test` 와 proxy workspace tests 가 모두 통과
## Manual verification
1. `node packages/k-skill-proxy/src/server.js` 로 로컬 서버 기동
2. health 와 새 HRFCO endpoint 를 실제 HTTP 요청으로 확인
3. station name / station code / 잘못된 입력에 대한 보수적 응답 확인

View file

@ -33,8 +33,8 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 한국 부동산 실거래가 조회 | 아파트/오피스텔/빌라/단독주택 실거래가·전월세·지역코드 조회 | 불필요 | [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md) |
| 생활쓰레기 배출정보 조회 | 시군구 기준 생활쓰레기·음식물·재활용 배출요일·시간·장소·관리부서 확인 | 불필요 | [생활쓰레기 배출정보 조회 가이드](docs/features/household-waste-info.md) |
| 학교 급식 식단 조회 | 교육청·학교명으로 NEIS 학교 검색·급식 식단 조회 | 불필요 | [학교 급식 식단 조회 가이드](docs/features/k-schoollunch-menu.md) |
| 의약품 안전 체크 | 식약처 e약은요·안전상비의약품 정보를 인터뷰-first 흐름으로 조회 | 필요 | [의약품 안전 체크 가이드](docs/features/mfds-drug-safety.md) |
| 식품 안전 체크 | 식약처 부적합 식품·식품안전나라 회수 정보를 인터뷰-first 흐름으로 조회 | 필요 | [식품 안전 체크 가이드](docs/features/mfds-food-safety.md) |
| 의약품 안전 체크 | 식약처 e약은요·안전상비의약품 정보를 인터뷰-first 흐름으로 프록시 조회 | 필요 | [의약품 안전 체크 가이드](docs/features/mfds-drug-safety.md) |
| 식품 안전 체크 | 식약처 부적합 식품·식품안전나라 회수 정보를 인터뷰-first 흐름으로 프록시 조회 | 필요 | [식품 안전 체크 가이드](docs/features/mfds-food-safety.md) |
| 한국 주식 정보 조회 | KRX 상장 종목 검색, 기본정보, 일별 시세 조회 | 불필요 | [한국 주식 정보 조회 가이드](docs/features/korean-stock-search.md) |
| 조선왕조실록 검색 | 조선왕조실록 키워드 검색과 왕별/연도별 필터, 기사 발췌 조회 | 불필요 | [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md) |
| 한국 특허 정보 검색 | 한국 특허/실용신안 키워드 검색 및 출원번호 상세 조회 | 필요 | [한국 특허 정보 검색 가이드](docs/features/korean-patent-search.md) |

View file

@ -20,6 +20,8 @@ client/skill -> k-skill-proxy -> upstream public API
- `GET /v1/seoul-subway/arrival`
- `GET /v1/han-river/water-level`
- `GET /v1/household-waste/info` (생활쓰레기 배출정보, `DATA_GO_KR_API_KEY`; 쿼리 `pageNo`·`numOfRows` 필수, 값 `1`·`100`)
- `GET /v1/mfds/drug-safety/lookup` (식약처 의약품개요정보 + 안전상비의약품 정보, `DATA_GO_KR_API_KEY`)
- `GET /v1/mfds/food-safety/search` (식약처 부적합 식품 + 식품안전나라 회수 정보, `DATA_GO_KR_API_KEY`, 선택적 `FOODSAFETYKOREA_API_KEY`)
- `GET /v1/korean-stock/search`
- `GET /v1/korean-stock/base-info`
- `GET /v1/korean-stock/trade-info`
@ -43,6 +45,7 @@ client/skill -> k-skill-proxy -> upstream public API
- `HRFCO_OPEN_API_KEY=...`
- `OPINET_API_KEY=...`
- `DATA_GO_KR_API_KEY=...`
- `FOODSAFETYKOREA_API_KEY=...` (선택: 식품안전나라 회수 live 결과, 없으면 sample fallback)
- `KEDU_INFO_KEY=...` (나이스 교육정보 개방 포털 Open API 인증키)
- `KRX_API_KEY=...`
- `KSKILL_PROXY_PORT=4020`
@ -165,6 +168,23 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/household-waste/info' \
--data-urlencode 'numOfRows=100'
```
의약품 안전 체크 endpoint:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/mfds/drug-safety/lookup' \
--data-urlencode 'itemName=타이레놀' \
--data-urlencode 'itemName=판콜' \
--data-urlencode 'limit=5'
```
식품 안전 체크 endpoint:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/mfds/food-safety/search' \
--data-urlencode 'query=김밥' \
--data-urlencode 'limit=5'
```
한국 주식 검색 endpoint:
```bash

View file

@ -6,13 +6,15 @@
- 식약처 공식 `안전상비의약품 정보` 조회
- 제품명 기준으로 효능, 사용법, 주의사항, 상호작용, 이상반응, 보관법 요약
- 증상 언급 시 **인터뷰-first** 흐름으로 red flag 확인
- 사용자 API key 없이 `k-skill-proxy` 경유 조회
## 먼저 필요한 것
- 인터넷 연결
- `python3`
- `DATA_GO_KR_API_KEY`
- 설치된 `mfds-drug-safety` skill 안에 `scripts/mfds_drug_safety.py` helper 포함
- `k-skill-proxy``/v1/mfds/drug-safety/lookup` route가 있는 hosted/self-host 프록시 접근
- `DATA_GO_KR_API_KEY` 는 사용자 쪽이 아니라 **프록시 운영 서버** 환경에 있어야 한다
> 이 helper 는 증상 질문에 대한 직접 진단을 하지 않는다. 증상이 있으면 바로 단정하지 말고 먼저 되묻는다.
@ -22,6 +24,7 @@
- e약은요 endpoint: `https://apis.data.go.kr/1471000/DrbEasyDrugInfoService/getDrbEasyDrugList`
- 공공데이터포털 문서: `https://www.data.go.kr/data/15097208/openapi.do`
- 안전상비의약품 endpoint: `https://apis.data.go.kr/1471000/SafeStadDrugService/getSafeStadDrugInq`
- 프록시 route: `https://k-skill-proxy.nomadamas.org/v1/mfds/drug-safety/lookup`
## 권장 인터뷰 질문
@ -39,7 +42,7 @@ red flag 가 있으면 **즉시 119·응급실·의료진** 안내가 우선이
## 기본 흐름
1. `python3 scripts/mfds_drug_safety.py interview ...` 로 되묻기 질문 세트를 준비한다.
2. red flag 가 없고 약 이름이 확인되면 `lookup` 으로 공식 정보를 조회한다.
2. red flag 가 없고 약 이름이 확인되면 `lookup` 으로 프록시 route를 조회한다.
3. 효능/주의/상호작용/부작용을 짧게 정리한다.
4. `같이 먹어도 되나?` 질문에는 공식 문구를 근거로만 말하고 최종 판단은 약사·의료진 확인이 필요하다고 밝힌다.
@ -52,7 +55,6 @@ python3 scripts/mfds_drug_safety.py interview \
```
```bash
export DATA_GO_KR_API_KEY=your-service-key
python3 scripts/mfds_drug_safety.py lookup --item-name "타이레놀" --item-name "판콜"
```
@ -72,16 +74,19 @@ python3 scripts/mfds_drug_safety.py lookup --item-name "타이레놀" --item-nam
"efficacy": "감기로 인한 발열 및 동통에 사용합니다.",
"interactions": "다른 해열진통제와 함께 복용하지 마십시오."
}
]
],
"proxy": {
"name": "k-skill-proxy"
}
}
```
## 검증 메모
2026-04-08 기준 로컬에서 아래를 실제 실행해 helper 동작을 확인했다.
2026-04-13 기준 로컬에서 아래를 실제 실행해 helper 동작을 확인했다.
- `python3 scripts/mfds_drug_safety.py --help`
- `python3 scripts/mfds_drug_safety.py interview --question "타이레놀이랑 판콜 같이 먹어도 되나요?" --symptoms "두드러기와 어지러움"`
- `DATA_GO_KR_API_KEY` 를 소스한 뒤 live endpoint 호출을 시도해 현재 키/활용승인 상태에서 `HTTP 403` 이 surfaced 되는지 확인
- 프록시 route 기준으로 `lookup` 호출 URL 구성이 `/v1/mfds/drug-safety/lookup` 로 향하는지 검증
즉, helper 자체와 인터뷰 흐름은 검증했고, live 성공 경로는 해당 서비스 활용승인/키 상태가 준비된 환경에서 바로 이어서 검증할 수 있다.
즉, helper 자체와 인터뷰 흐름은 검증했고, live 성공 경로는 프록시 서버에 `DATA_GO_KR_API_KEY` 가 준비된 환경에서 바로 이어서 검증할 수 있다.

View file

@ -3,17 +3,18 @@
## 이 기능으로 할 수 있는 일
- 식약처 공식 부적합 식품 목록 조회
- 식품안전나라 회수·판매중지 공개 목록(sample/live) 확인
- 식품안전나라 회수·판매중지 공개 목록 확인
- 제품명/업체명 기준 로컬 필터링 요약
- 증상 언급 시 **인터뷰-first** 흐름으로 red flag 확인
- 사용자 API key 없이 `k-skill-proxy` 경유 조회
## 먼저 필요한 것
- 인터넷 연결
- `python3`
- 부적합 식품 live 조회용 `DATA_GO_KR_API_KEY`
- 회수정보 smoke/demo 용 `--sample-recalls` 또는 식품안전나라 API key
- 설치된 `mfds-food-safety` skill 안에 `scripts/mfds_food_safety.py` helper 포함
- `k-skill-proxy``/v1/mfds/food-safety/search` route가 있는 hosted/self-host 프록시 접근
- `DATA_GO_KR_API_KEY` / 선택적 `FOODSAFETYKOREA_API_KEY` 는 사용자 쪽이 아니라 **프록시 운영 서버** 환경에 있어야 한다
> 이 helper 는 **직접 진단**을 하지 않는다. 먹어도 되는지 바로 단정하지 않는다. 증상이 있으면 바로 단정하지 말고 먼저 되묻는다.
@ -24,6 +25,7 @@
- 식품안전나라 회수·판매중지 문서: `https://www.data.go.kr/data/15074318/openapi.do`
- 식품안전나라 API 안내: `https://www.foodsafetykorea.go.kr/api/openApiInfo.do?menu_grp=MENU_GRP31&menu_no=661&show_cnt=10&start_idx=1&svc_no=I0490&svc_type_cd=API_TYPE06`
- 식품안전나라 회수 sample: `https://openapi.foodsafetykorea.go.kr/api/sample/I0490/json/1/5`
- 프록시 route: `https://k-skill-proxy.nomadamas.org/v1/mfds/food-safety/search`
## 권장 인터뷰 질문
@ -41,8 +43,8 @@ red flag 가 있으면 **즉시 응급실·119·의료진** 안내가 우선이
## 기본 흐름
1. `python3 scripts/mfds_food_safety.py interview ...` 로 되묻기 질문 세트를 준비한다.
2. 부적합 식품 live 조회가 가능하면 `PrsecImproptFoodInfoService03/getPrsecImproptFoodList01` 를 조회한다.
3. 필요하면 식품안전나라 `I0490` 회수 sample/live 목록을 함께 확인한다.
2. `search` 로 프록시 route를 조회한다.
3. 프록시가 준비된 upstream key에 따라 부적합 식품 live 결과와 식품안전나라 회수 live/sample 결과를 합쳐 준다.
4. 제품명/업체명/사유 기준으로 로컬 필터링 후 짧게 정리한다.
5. 먹어도 되는지 단정하지 않고, 증상이 있으면 의료진 상담을 우선한다.
@ -55,11 +57,6 @@ python3 scripts/mfds_food_safety.py interview \
```
```bash
python3 scripts/mfds_food_safety.py search --query "김밥" --sample-recalls --limit 5
```
```bash
export DATA_GO_KR_API_KEY=your-service-key
python3 scripts/mfds_food_safety.py search --query "김밥" --limit 5
```
@ -76,17 +73,21 @@ python3 scripts/mfds_food_safety.py search --query "김밥" --limit 5
"reason": "대장균 기준 규격 부적합"
}
],
"warnings": []
"warnings": [
"FOODSAFETYKOREA_API_KEY is not configured on the proxy server, so recall results use the public sample feed."
],
"proxy": {
"name": "k-skill-proxy"
}
}
```
## 검증 메모
2026-04-08 기준 로컬에서 아래를 실제 실행해 helper 동작을 확인했다.
2026-04-13 기준 로컬에서 아래를 실제 실행해 helper 동작을 확인했다.
- `python3 scripts/mfds_food_safety.py --help`
- `python3 scripts/mfds_food_safety.py interview --question "이 김밥 먹어도 되나요?" --symptoms "복통과 설사"`
- `python3 scripts/mfds_food_safety.py search --query "김밥" --sample-recalls --limit 5`
- `DATA_GO_KR_API_KEY` 를 소스한 뒤 live 부적합 식품 endpoint 호출을 시도해 현재 키/활용승인 상태에서 `HTTP 403` 이 surfaced 되는지 확인
- 프록시 route 기준으로 `search` 호출 URL 구성이 `/v1/mfds/food-safety/search` 로 향하는지 검증
즉, helper 자체와 공개 sample 회수 흐름은 검증했고, live 성공 경로는 해당 서비스 활용승인/키 상태가 준비된 환경에서 바로 이어서 검증할 수 있다.
즉, helper 자체와 인터뷰 흐름은 검증했고, live 성공 경로는 프록시 서버에 `DATA_GO_KR_API_KEY` / `FOODSAFETYKOREA_API_KEY` 가 준비된 환경에서 바로 이어서 검증할 수 있다.

View file

@ -30,7 +30,7 @@ AIR_KOREA_OPEN_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
```
서울 지하철 도착정보와 한국 날씨 조회는 hosted public route rollout 이 끝나기 전까지 self-host 또는 배포 확인이 끝난 proxy URL 만 넣는다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다. 생활쓰레기 배출정보는 `k-skill-proxy``/v1/household-waste/info` 라우트를 거쳐 `serviceKey`만 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다.
서울 지하철 도착정보와 한국 날씨 조회는 hosted public route rollout 이 끝나기 전까지 self-host 또는 배포 확인이 끝난 proxy URL 만 넣는다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, 의약품 안전 체크, 식품 안전 체크는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다. 생활쓰레기 배출정보는 `k-skill-proxy``/v1/household-waste/info` 라우트를 거쳐 `serviceKey`만 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다.
## Missing secret handling policy
@ -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`(`DATA_GO_KR_API_KEY`)를 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다. 의약품 안전 체크도 `k-skill-proxy``/v1/mfds/drug-safety/lookup` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 식품 안전 체크는 `k-skill-proxy``/v1/mfds/food-safety/search` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 및 선택적 `FOODSAFETYKOREA_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`, `FOODSAFETYKOREA_API_KEY`, `KRX_API_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`·`FOODSAFETYKOREA_API_KEY` 등은 서버에 설정되어 있어야 한다).
## 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`)를 그대로 쓴다.
한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC``korean-law-mcp` 로컬 실행에 쓴다. 로컬 CLI/MCP 경로는 `LAW_OC` 를 채운 뒤 `npm install -g korean-law-mcp``korean-law list` 로 설치 상태를 확인한다.
@ -80,6 +80,8 @@ bash scripts/check-setup.sh
| 한강 수위 정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
| 생활쓰레기 배출정보 조회 | 사용자 시크릿 불필요 (프록시에 `DATA_GO_KR_API_KEY`가 설정된 hosted/self-host; API 호출 시 `pageNo=1`, `numOfRows=100` 필수) |
| 학교 급식 식단 조회 | 사용자 시크릿 불필요 (프록시에 `KEDU_INFO_KEY`가 설정된 hosted/self-host 사용) |
| 의약품 안전 체크 | 사용자 시크릿 불필요 (프록시에 `DATA_GO_KR_API_KEY`가 설정된 hosted/self-host 사용) |
| 식품 안전 체크 | 사용자 시크릿 불필요 (프록시에 `DATA_GO_KR_API_KEY`와 선택적 `FOODSAFETYKOREA_API_KEY`가 설정된 hosted/self-host 사용) |
## 다음에 볼 문서
@ -97,6 +99,8 @@ bash scripts/check-setup.sh
- [근처 가장 싼 주유소 찾기 가이드](features/cheap-gas-nearby.md)
- [생활쓰레기 배출정보 조회 가이드](features/household-waste-info.md)
- [학교 급식 식단 조회 가이드](features/k-schoollunch-menu.md)
- [의약품 안전 체크 가이드](features/mfds-drug-safety.md)
- [식품 안전 체크 가이드](features/mfds-food-safety.md)
- [보안/시크릿 정책](security-and-secrets.md)
설치 기본 흐름은 "전체 스킬 설치 → 개별 기능 사용" 이다.

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`)를 그대로 쓴다.
한국 법령 검색은 로컬 `korean-law-mcp` 경로를 쓸 때만 `LAW_OC` 를 채운다. remote endpoint는 사용자 `LAW_OC` 없이 `url`만 등록하면 되고, 기존 경로 장애 시에는 `법망`(`https://api.beopmang.org`)을 fallback으로 안내한다.
@ -91,6 +91,10 @@ chmod 0600 ~/.config/k-skill/secrets.env
근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다.
의약품 안전 체크는 `k-skill-proxy``/v1/mfds/drug-safety/lookup` 라우트를 호출하고, `DATA_GO_KR_API_KEY` 는 프록시 서버에서만 주입/관리하므로 사용자 쪽에 둘 필요가 없다.
식품 안전 체크는 `k-skill-proxy``/v1/mfds/food-safety/search` 라우트를 호출하고, `DATA_GO_KR_API_KEY` 및 선택적 `FOODSAFETYKOREA_API_KEY` 는 프록시 서버에서만 주입/관리하므로 사용자 쪽에 둘 필요가 없다.
한국 특허 정보 검색은 KIPRIS Plus Open API 경로를 쓸 때 `KIPRIS_PLUS_API_KEY` 를 채운다. helper는 이 값을 읽어 실제 요청에서 `ServiceKey` 쿼리 파라미터로 보낸다. 공공데이터포털에서 복사한 percent-encoded key도 그대로 넣어도 된다.
@ -109,6 +113,8 @@ chmod 0600 ~/.config/k-skill/secrets.env
- 한국 주식 정보 조회: 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KRX_API_KEY`)
- 생활쓰레기 배출정보 조회: 사용자 시크릿 불필요 (`serviceKey`는 proxy 서버 주입, 호출 시 `pageNo=1`·`numOfRows=100` 필수)
- 학교 급식 식단 조회: 사용자 시크릿 불필요 (`KEDU_INFO_KEY`는 proxy 서버만)
- 의약품 안전 체크: 사용자 시크릿 불필요 (`DATA_GO_KR_API_KEY`는 proxy 서버만)
- 식품 안전 체크: 사용자 시크릿 불필요 (`DATA_GO_KR_API_KEY`와 선택적 `FOODSAFETYKOREA_API_KEY`는 proxy 서버만)
- 근처 가장 싼 주유소 찾기: 사용자 시크릿 불필요 (기본 hosted proxy 사용)
- 서울 지하철: self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL`
- 한국 날씨: self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL`

View file

@ -1,6 +1,6 @@
---
name: mfds-drug-safety
description: 식약처 공공 OpenAPI로 의약품 안전정보를 조회하기 전에 증상·복용상황을 반드시 되묻는 인터뷰형 의약품 안전 체크 스킬.
description: 식약처 공공 OpenAPI를 k-skill-proxy 경유로 조회하기 전에 증상·복용상황을 반드시 되묻는 인터뷰형 의약품 안전 체크 스킬.
license: MIT
metadata:
category: public-health
@ -12,7 +12,7 @@ metadata:
## What this skill does
식약처 공식 OpenAPI를 사용해 **의약품개요정보(e약은요)** 와 **안전상비의약품 정보**를 조회한다.
식약처 공식 OpenAPI를 **`k-skill-proxy` 경유**로 조회해 **의약품개요정보(e약은요)** 와 **안전상비의약품 정보**를 확인한다.
하지만 사용자가 증상이나 복용 상황을 말하면 **바로 단정하지 말고 먼저 되묻는다.**
@ -35,8 +35,14 @@ red flag 가 있으면 API 조회보다 **즉시 119·응급실·의료진 연
- 인터넷 연결
- `python3`
- 공공데이터포털 식약처 API 활용승인 후 발급된 `DATA_GO_KR_API_KEY`
- 설치된 skill payload 안에 `scripts/mfds_drug_safety.py` helper 포함
- `k-skill-proxy``/v1/mfds/drug-safety/lookup` route가 있는 hosted/self-host 프록시에 접근 가능할 것
## Credential requirements
- 사용자 측 **필수** 시크릿 없음.
- `KSKILL_PROXY_BASE_URL` — self-host·별도 프록시를 쓸 때만 설정. 비우면 기본 hosted `https://k-skill-proxy.nomadamas.org` 를 사용한다.
- `DATA_GO_KR_API_KEY`**프록시 운영 서버** 환경에만 둔다.
## Mandatory interview first
@ -53,12 +59,13 @@ red flag 가 있으면 API 조회보다 **즉시 119·응급실·의료진 연
- e약은요 endpoint: `https://apis.data.go.kr/1471000/DrbEasyDrugInfoService/getDrbEasyDrugList`
- 공공데이터포털 문서: `https://www.data.go.kr/data/15097208/openapi.do`
- 안전상비의약품 endpoint: `https://apis.data.go.kr/1471000/SafeStadDrugService/getSafeStadDrugInq`
- 프록시 route: `GET /v1/mfds/drug-safety/lookup`
## Workflow
1. 증상/복용상황이 있으면 인터뷰를 먼저 진행한다.
2. red flag 가 하나라도 있으면 즉시 응급 안내로 전환한다.
3. 약 이름이 확인되면 `DrbEasyDrugInfoService/getDrbEasyDrugList` 와 `SafeStadDrugService/getSafeStadDrugInq` 로 공식 정보를 조회한다.
3. 약 이름이 확인되면 `k-skill-proxy`의 `/v1/mfds/drug-safety/lookup`로 공식 정보를 조회한다.
4. 효능, 사용법, 주의사항, 상호작용, 이상반응, 보관법을 짧게 정리한다.
5. `같이 먹어도 되나?` 질문에는 공식 상호작용 문구만 근거로 제시하고, 최종 판단은 약사·의료진 확인이 필요하다고 명시한다.
@ -71,7 +78,6 @@ python3 scripts/mfds_drug_safety.py interview \
```
```bash
export DATA_GO_KR_API_KEY=your-service-key
python3 scripts/mfds_drug_safety.py lookup --item-name "타이레놀" --item-name "판콜"
```
@ -86,5 +92,5 @@ python3 scripts/mfds_drug_safety.py lookup --item-name "타이레놀" --item-nam
- 증상 또는 복용상황을 먼저 되물었다.
- red flag 여부를 확인했다.
- `DATA_GO_KR_API_KEY` 가 준비된 경우 공식 endpoint 조회 결과를 JSON으로 정리했다.
- 프록시 route를 통해 공식 endpoint 조회 결과를 JSON으로 정리했다.
- 최소한 제품명, 업체명, 효능/주의/상호작용이 포함된 요약을 제공했다.

View file

@ -11,8 +11,8 @@ import urllib.request
from html import unescape
from typing import Any
DRUG_EASY_ENDPOINT = "https://apis.data.go.kr/1471000/DrbEasyDrugInfoService/getDrbEasyDrugList"
SAFE_STAD_ENDPOINT = "https://apis.data.go.kr/1471000/SafeStadDrugService/getSafeStadDrugInq"
PROXY_BASE_URL_ENV_VAR = "KSKILL_PROXY_BASE_URL"
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
class ApiError(RuntimeError):
@ -31,12 +31,14 @@ def summarize_text(value: Any) -> str:
return text
def resolve_service_key(explicit_key: str | None, env: dict[str, str] | None = None) -> str:
def resolve_proxy_base_url(explicit_base_url: str | None = None, env: dict[str, str] | None = None) -> str:
env = env or os.environ
candidate = explicit_key or env.get("DATA_GO_KR_API_KEY")
if not candidate:
raise ValueError("DATA_GO_KR_API_KEY 또는 --service-key 가 필요합니다.")
return urllib.parse.unquote(str(candidate).strip())
candidate = summarize_text(explicit_base_url or env.get(PROXY_BASE_URL_ENV_VAR))
if candidate.casefold() in {"off", "false", "0", "disable", "disabled", "none"}:
raise ValueError("KSKILL_PROXY_BASE_URL 가 비활성화되어 있습니다.")
if candidate and candidate != "replace-me":
return candidate.rstrip("/")
return DEFAULT_PROXY_BASE_URL
def build_drug_interview(question: str | None = None, symptoms: str | None = None) -> dict[str, Any]:
@ -63,27 +65,27 @@ def build_drug_interview(question: str | None = None, symptoms: str | None = Non
EASY_FIELD_MAP = {
"item_name": "itemName",
"company_name": "entpName",
"efficacy": "efcyQesitm",
"how_to_use": "useMethodQesitm",
"warnings": "atpnWarnQesitm",
"cautions": "atpnQesitm",
"interactions": "intrcQesitm",
"side_effects": "seQesitm",
"storage": "depositMethodQesitm",
"item_seq": "itemSeq",
"item_name": "item_name",
"company_name": "company_name",
"efficacy": "efficacy",
"how_to_use": "how_to_use",
"warnings": "warnings",
"cautions": "cautions",
"interactions": "interactions",
"side_effects": "side_effects",
"storage": "storage",
"item_seq": "item_seq",
}
SAFE_STAD_FIELD_MAP = {
"item_name": "PRDLST_NM",
"company_name": "BSSH_NM",
"efficacy": "EFCY_QESITM",
"how_to_use": "USE_METHOD_QESITM",
"warnings": "ATPN_WARN_QESITM",
"cautions": "ATPN_QESITM",
"interactions": "INTRC_QESITM",
"side_effects": "SE_QESITM",
"item_name": "item_name",
"company_name": "company_name",
"efficacy": "efficacy",
"how_to_use": "how_to_use",
"warnings": "warnings",
"cautions": "cautions",
"interactions": "interactions",
"side_effects": "side_effects",
}
@ -99,70 +101,45 @@ def normalize_safe_stad_item(item: dict[str, Any]) -> dict[str, Any]:
return normalized
def _extract_items(payload: dict[str, Any]) -> list[dict[str, Any]]:
body = payload.get("body") or {}
items = body.get("items") or {}
raw = items.get("item")
if raw is None:
return []
if isinstance(raw, list):
return [item for item in raw if isinstance(item, dict)]
if isinstance(raw, dict):
return [raw]
return []
def _request_json(url: str, params: dict[str, Any]) -> dict[str, Any]:
query = urllib.parse.urlencode({key: value for key, value in params.items() if value not in (None, "")})
request = urllib.request.Request(f"{url}?{query}", headers={"Accept": "application/json", "User-Agent": "k-skill-mfds/1.0"})
def read_json_response(request: urllib.request.Request | str) -> dict[str, Any]:
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
raise ApiError(f"MFDS request failed with HTTP {error.code}", status_code=error.code, url=request.full_url) from error
body = error.read().decode("utf-8", errors="replace")
try:
payload = json.loads(body)
except json.JSONDecodeError:
payload = None
if isinstance(payload, dict) and payload.get("message"):
raise ApiError(str(payload["message"]), status_code=error.code, url=getattr(error, "url", None)) from error
raise ApiError(
f"MFDS drug proxy request failed with HTTP {error.code}",
status_code=error.code,
url=getattr(error, "url", None),
) from error
except urllib.error.URLError as error:
raise ApiError(f"MFDS drug proxy request failed: {error.reason}") from error
def lookup_drugs(
item_names: list[str],
*,
service_key: str,
limit: int = 5,
request_json: Any = _request_json,
base_url: str | None = None,
request_json: Any = read_json_response,
) -> dict[str, Any]:
normalized_items: list[dict[str, Any]] = []
for item_name in item_names:
easy_payload = request_json(
DRUG_EASY_ENDPOINT,
{
"ServiceKey": service_key,
"pageNo": 1,
"numOfRows": limit,
"type": "json",
"itemName": item_name,
},
)
easy_items = [normalize_easy_drug_item(item) for item in _extract_items(easy_payload)]
safe_payload = request_json(
SAFE_STAD_ENDPOINT,
{
"serviceKey": service_key,
"pageNo": 1,
"numOfRows": limit,
"type": "json",
"PRDLST_NM": item_name,
},
)
safe_items = [normalize_safe_stad_item(item) for item in _extract_items(safe_payload)]
normalized_items.extend(easy_items)
normalized_items.extend(safe_items)
return {
"query": {"item_names": item_names, "limit": limit},
"items": normalized_items,
"note": "상호작용 문구는 공식 품목 안내를 그대로 요약한 참고 정보이며, 복용 가능 여부의 최종 판단은 약사·의료진 확인이 필요합니다.",
}
resolved_base_url = resolve_proxy_base_url(base_url)
url = f"{resolved_base_url}/v1/mfds/drug-safety/lookup"
params: list[tuple[str, str]] = [("itemName", item_name) for item_name in item_names]
params.append(("limit", str(limit)))
query = urllib.parse.urlencode(params)
request = urllib.request.Request(
f"{url}?{query}",
headers={"Accept": "application/json", "User-Agent": "k-skill-mfds/1.0"},
)
return request_json(request)
def build_parser() -> argparse.ArgumentParser:
@ -173,10 +150,10 @@ def build_parser() -> argparse.ArgumentParser:
interview.add_argument("--question", default="")
interview.add_argument("--symptoms", default="")
lookup = subparsers.add_parser("lookup", help="look up official MFDS drug safety records")
lookup = subparsers.add_parser("lookup", help="look up official MFDS drug safety records through k-skill-proxy")
lookup.add_argument("--item-name", action="append", required=True)
lookup.add_argument("--service-key")
lookup.add_argument("--limit", type=int, default=5)
lookup.add_argument("--proxy-base-url")
return parser
@ -189,8 +166,7 @@ def main(argv: list[str] | None = None) -> int:
if args.command == "lookup":
try:
service_key = resolve_service_key(args.service_key)
payload = lookup_drugs(args.item_name, service_key=service_key, limit=args.limit)
payload = lookup_drugs(args.item_name, limit=args.limit, base_url=args.proxy_base_url)
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0
except (ValueError, ApiError) as error:

View file

@ -1,6 +1,6 @@
---
name: mfds-food-safety
description: 식약처/식품안전나라 공개 표면으로 식품 회수·부적합 정보를 조회하기 전에 증상·섭취상황을 반드시 되묻는 인터뷰형 식품 안전 체크 스킬.
description: 식약처/식품안전나라 공개 표면을 k-skill-proxy 경유로 조회하기 전에 증상·섭취상황을 반드시 되묻는 인터뷰형 식품 안전 체크 스킬.
license: MIT
metadata:
category: public-health
@ -12,7 +12,7 @@ metadata:
## What this skill does
식약처/식품안전나라 공개 표면으로 **부적합 식품 목록**과 **회수·판매중지 공개 목록**을 확인한다.
식약처/식품안전나라 공개 표면**`k-skill-proxy` 경유**로 조회해 **부적합 식품 목록**과 **회수·판매중지 공개 목록**을 확인한다.
하지만 사용자가 복통, 설사, 발진 같은 증상을 말하면 **바로 단정하지 말고 먼저 되묻는다.**
@ -36,9 +36,15 @@ red flag 가 있으면 식품 조회보다 **즉시 응급실·119·의료진
- 인터넷 연결
- `python3`
- 부적합 식품 live 조회용 `DATA_GO_KR_API_KEY` (공공데이터포털)
- 회수정보 smoke/demo 용 `--sample-recalls` 또는 식품안전나라 API key
- 설치된 skill payload 안에 `scripts/mfds_food_safety.py` helper 포함
- `k-skill-proxy``/v1/mfds/food-safety/search` route가 있는 hosted/self-host 프록시에 접근 가능할 것
## Credential requirements
- 사용자 측 **필수** 시크릿 없음.
- `KSKILL_PROXY_BASE_URL` — self-host·별도 프록시를 쓸 때만 설정. 비우면 기본 hosted `https://k-skill-proxy.nomadamas.org` 를 사용한다.
- `DATA_GO_KR_API_KEY`**프록시 운영 서버** 환경에서 부적합 식품 live 조회용으로만 둔다.
- `FOODSAFETYKOREA_API_KEY`**프록시 운영 서버** 환경에서 식품안전나라 회수 live 조회용으로만 둔다. 없으면 public sample 회수 feed로 fallback 할 수 있다.
## Mandatory interview first
@ -56,14 +62,15 @@ red flag 가 있으면 식품 조회보다 **즉시 응급실·119·의료진
- 식품안전나라 회수·판매중지 문서: `https://www.data.go.kr/data/15074318/openapi.do`
- 식품안전나라 API 안내: `https://www.foodsafetykorea.go.kr/api/openApiInfo.do?menu_grp=MENU_GRP31&menu_no=661&show_cnt=10&start_idx=1&svc_no=I0490&svc_type_cd=API_TYPE06`
- 식품안전나라 회수 sample: `https://openapi.foodsafetykorea.go.kr/api/sample/I0490/json/1/5`
- 프록시 route: `GET /v1/mfds/food-safety/search`
## Workflow
1. 증상/섭취상황이 있으면 인터뷰를 먼저 진행한다.
2. red flag 가 있으면 즉시 응급 안내로 전환한다.
3. `PrsecImproptFoodInfoService03/getPrsecImproptFoodList01` 로 부적합 식품 목록을 가져와 제품명/업체명 기준으로 로컬 필터링한다.
4. 필요하면 식품안전나라 `I0490` 회수 sample/live 목록도 함께 확인한다.
5. 제품명, 업체명, 회수/부적합 사유, 공개일자를 짧게 정리하고, 먹어도 되는지 단정하지 않는다.
3. `k-skill-proxy`의 `/v1/mfds/food-safety/search` 로 부적합 식품/회수 공개 목록을 조회한다.
4. 제품명, 업체명, 회수/부적합 사유, 공개일자를 짧게 정리하고, 먹어도 되는지 단정하지 않는다.
5. 프록시가 `FOODSAFETYKOREA_API_KEY` 없이 동작 중이면 회수 정보가 sample feed 기반일 수 있음을 warnings 로 확인한다.
## CLI examples
@ -74,11 +81,6 @@ python3 scripts/mfds_food_safety.py interview \
```
```bash
python3 scripts/mfds_food_safety.py search --query "김밥" --sample-recalls --limit 5
```
```bash
export DATA_GO_KR_API_KEY=your-service-key
python3 scripts/mfds_food_safety.py search --query "김밥" --limit 5
```
@ -94,5 +96,5 @@ python3 scripts/mfds_food_safety.py search --query "김밥" --limit 5
- 증상 또는 섭취상황을 먼저 되물었다.
- red flag 여부를 확인했다.
- 공식 공개 목록에서 제품명 또는 업체명 기준 결과를 최소 1건 이상 찾았거나, 없다고 분명히 알렸다.
- 프록시 route를 통해 공식 공개 목록에서 제품명 또는 업체명 기준 결과를 최소 1건 이상 찾았거나, 없다고 분명히 알렸다.
- 제품명, 업체명, 공개사유/부적합 사유, 공개일자를 포함한 요약을 제공했다.

View file

@ -11,9 +11,8 @@ import urllib.request
from html import unescape
from typing import Any
IMPROPER_FOOD_ENDPOINT = "https://apis.data.go.kr/1471000/PrsecImproptFoodInfoService03/getPrsecImproptFoodList01"
FOOD_RECALL_SAMPLE_URL = "https://openapi.foodsafetykorea.go.kr/api/sample/I0490/json/{start}/{end}"
FOOD_RECALL_LIVE_URL = "https://openapi.foodsafetykorea.go.kr/api/{api_key}/I0490/json/{start}/{end}"
PROXY_BASE_URL_ENV_VAR = "KSKILL_PROXY_BASE_URL"
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
class ApiError(RuntimeError):
@ -32,12 +31,14 @@ def summarize_text(value: Any) -> str:
return text
def resolve_data_go_service_key(explicit_key: str | None, env: dict[str, str] | None = None) -> str:
def resolve_proxy_base_url(explicit_base_url: str | None = None, env: dict[str, str] | None = None) -> str:
env = env or os.environ
candidate = explicit_key or env.get("DATA_GO_KR_API_KEY")
if not candidate:
raise ValueError("DATA_GO_KR_API_KEY 또는 --service-key 가 필요합니다.")
return urllib.parse.unquote(str(candidate).strip())
candidate = summarize_text(explicit_base_url or env.get(PROXY_BASE_URL_ENV_VAR))
if candidate.casefold() in {"off", "false", "0", "disable", "disabled", "none"}:
raise ValueError("KSKILL_PROXY_BASE_URL 가 비활성화되어 있습니다.")
if candidate and candidate != "replace-me":
return candidate.rstrip("/")
return DEFAULT_PROXY_BASE_URL
def build_food_interview(question: str | None = None, symptoms: str | None = None) -> dict[str, Any]:
@ -66,24 +67,27 @@ def build_food_interview(question: str | None = None, symptoms: str | None = Non
def normalize_food_recall_row(row: dict[str, Any]) -> dict[str, Any]:
return {
"source": "foodsafetykorea_recall",
"product_name": summarize_text(row.get("PRDLST_NM") or row.get("PRDTNM")),
"company_name": summarize_text(row.get("BSSH_NM") or row.get("BSSHNM")),
"reason": summarize_text(row.get("RTRVLPRVNS")),
"created_at": summarize_text(row.get("CRET_DTM")),
"distribution_deadline": summarize_text(row.get("DISTBTMLMT")),
"category": summarize_text(row.get("PRDLST_TYPE") or row.get("PRDLST_CD_NM")),
"product_name": summarize_text(row.get("product_name") or row.get("PRDLST_NM") or row.get("PRDTNM")),
"company_name": summarize_text(row.get("company_name") or row.get("BSSH_NM") or row.get("BSSHNM")),
"reason": summarize_text(row.get("reason") or row.get("RTRVLPRVNS")),
"created_at": summarize_text(row.get("created_at") or row.get("CRET_DTM")),
"distribution_deadline": summarize_text(row.get("distribution_deadline") or row.get("DISTBTMLMT")),
"category": summarize_text(row.get("category") or row.get("PRDLST_TYPE") or row.get("PRDLST_CD_NM")),
}
def normalize_improper_food_item(item: dict[str, Any]) -> dict[str, Any]:
reason_parts = [summarize_text(item.get("IMPROPT_ITM")), summarize_text(item.get("INSPCT_RESULT"))]
reason_parts = [
summarize_text(item.get("reason") or item.get("IMPROPT_ITM")),
summarize_text(item.get("INSPCT_RESULT")),
]
return {
"source": "mfds_improper_food",
"product_name": summarize_text(item.get("PRDUCT")),
"company_name": summarize_text(item.get("ENTRPS")),
"product_name": summarize_text(item.get("product_name") or item.get("PRDUCT")),
"company_name": summarize_text(item.get("company_name") or item.get("ENTRPS")),
"reason": "; ".join(part for part in reason_parts if part),
"created_at": summarize_text(item.get("REGIST_DT")),
"category": summarize_text(item.get("FOOD_TY")),
"created_at": summarize_text(item.get("created_at") or item.get("REGIST_DT")),
"category": summarize_text(item.get("category") or item.get("FOOD_TY")),
}
@ -92,142 +96,54 @@ def filter_food_items(items: list[dict[str, Any]], query: str) -> list[dict[str,
if not needle:
return items
product_matches = [
item for item in items if needle in summarize_text(item.get("product_name")).casefold()
]
product_matches = [item for item in items if needle in summarize_text(item.get("product_name")).casefold()]
if product_matches:
return product_matches
company_matches = [
item for item in items if needle in summarize_text(item.get("company_name")).casefold()
]
company_matches = [item for item in items if needle in summarize_text(item.get("company_name")).casefold()]
if company_matches:
return company_matches
return [
item for item in items if needle in summarize_text(item.get("reason")).casefold()
]
return [item for item in items if needle in summarize_text(item.get("reason")).casefold()]
def _request_json(url: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
full_url = url
if params:
full_url = f"{url}?{urllib.parse.urlencode({key: value for key, value in params.items() if value not in (None, '')})}"
request = urllib.request.Request(full_url, headers={"Accept": "application/json", "User-Agent": "k-skill-mfds/1.0"})
def read_json_response(request: urllib.request.Request | str) -> dict[str, Any]:
try:
with urllib.request.urlopen(request, timeout=30) as response:
body = response.read().decode("utf-8", errors="replace")
try:
return json.loads(body)
except json.JSONDecodeError as error:
hostname = (urllib.parse.urlparse(request.full_url).hostname or "").casefold()
if hostname == "openapi.foodsafetykorea.go.kr":
raise ApiError(
"식품안전나라 응답이 JSON이 아닙니다. --foodsafetykorea-key 가 유효한지 확인하세요.",
url=request.full_url,
) from error
content_type = summarize_text(response.headers.get("Content-Type") or "unknown")
raise ApiError(
f"MFDS food response was not valid JSON (content-type: {content_type})",
url=request.full_url,
) from error
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
raise ApiError(f"MFDS food request failed with HTTP {error.code}", status_code=error.code, url=request.full_url) from error
body = error.read().decode("utf-8", errors="replace")
try:
payload = json.loads(body)
except json.JSONDecodeError:
payload = None
def _extract_improper_food_items(payload: dict[str, Any]) -> list[dict[str, Any]]:
body = payload.get("body") or {}
items = body.get("items") or {}
raw = items.get("item")
if raw is None:
return []
if isinstance(raw, list):
return [item for item in raw if isinstance(item, dict)]
if isinstance(raw, dict):
return [raw]
return []
def _extract_food_recall_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
root = payload.get("I0490") or {}
rows = root.get("row")
if rows is None:
return []
if isinstance(rows, list):
return [row for row in rows if isinstance(row, dict)]
if isinstance(rows, dict):
return [rows]
return []
def fetch_improper_food_items(service_key: str, *, limit: int = 100, request_json: Any = _request_json) -> list[dict[str, Any]]:
payload = request_json(
IMPROPER_FOOD_ENDPOINT,
{"ServiceKey": service_key, "pageNo": 1, "numOfRows": limit, "type": "json"},
)
return [normalize_improper_food_item(item) for item in _extract_improper_food_items(payload)]
def fetch_food_recall_rows(
*,
limit: int = 100,
sample: bool = False,
foodsafety_api_key: str | None = None,
request_json: Any = _request_json,
) -> list[dict[str, Any]]:
start = 1
end = max(limit, 1)
if sample or not foodsafety_api_key:
url = FOOD_RECALL_SAMPLE_URL.format(start=start, end=end)
else:
url = FOOD_RECALL_LIVE_URL.format(api_key=foodsafety_api_key, start=start, end=end)
payload = request_json(url)
return [normalize_food_recall_row(row) for row in _extract_food_recall_rows(payload)]
if isinstance(payload, dict) and payload.get("message"):
raise ApiError(str(payload["message"]), status_code=error.code, url=getattr(error, "url", None)) from error
raise ApiError(
f"MFDS food proxy request failed with HTTP {error.code}",
status_code=error.code,
url=getattr(error, "url", None),
) from error
except urllib.error.URLError as error:
raise ApiError(f"MFDS food proxy request failed: {error.reason}") from error
def search_food_safety(
query: str,
*,
service_key: str | None = None,
foodsafety_api_key: str | None = None,
sample_recalls: bool = False,
limit: int = 10,
request_json: Any = _request_json,
base_url: str | None = None,
request_json: Any = read_json_response,
) -> dict[str, Any]:
items: list[dict[str, Any]] = []
warnings: list[str] = []
if service_key:
try:
items.extend(fetch_improper_food_items(service_key, limit=max(limit * 5, 50), request_json=request_json))
except ApiError as error:
warnings.append(str(error))
else:
warnings.append("DATA_GO_KR_API_KEY 가 없어 부적합 식품 live 조회는 건너뜁니다.")
if sample_recalls or foodsafety_api_key:
try:
items.extend(
fetch_food_recall_rows(
limit=max(limit * 5, 50),
sample=sample_recalls,
foodsafety_api_key=foodsafety_api_key,
request_json=request_json,
)
)
except ApiError as error:
warnings.append(str(error))
else:
warnings.append("식품안전나라 회수 정보는 --sample-recalls 또는 --foodsafetykorea-key 가 필요합니다.")
filtered = filter_food_items(items, query)[:limit]
return {
"query": query,
"items": filtered,
"warnings": warnings,
"note": "이 결과는 공식 회수·부적합 공개 목록 기반 참고 정보이며, 먹어도 되는지의 최종 판단은 증상 인터뷰와 의료진 상담이 우선입니다.",
}
resolved_base_url = resolve_proxy_base_url(base_url)
url = f"{resolved_base_url}/v1/mfds/food-safety/search"
params = urllib.parse.urlencode({"query": query, "limit": str(limit)})
request = urllib.request.Request(
f"{url}?{params}",
headers={"Accept": "application/json", "User-Agent": "k-skill-mfds/1.0"},
)
return request_json(request)
def build_parser() -> argparse.ArgumentParser:
@ -238,12 +154,10 @@ def build_parser() -> argparse.ArgumentParser:
interview.add_argument("--question", default="")
interview.add_argument("--symptoms", default="")
search = subparsers.add_parser("search", help="search official food recall/improper food records")
search = subparsers.add_parser("search", help="search official food-safety records through k-skill-proxy")
search.add_argument("--query", required=True)
search.add_argument("--service-key")
search.add_argument("--foodsafetykorea-key")
search.add_argument("--sample-recalls", action="store_true")
search.add_argument("--limit", type=int, default=10)
search.add_argument("--proxy-base-url")
return parser
@ -256,19 +170,10 @@ def main(argv: list[str] | None = None) -> int:
if args.command == "search":
try:
service_key = None
if args.service_key or os.environ.get("DATA_GO_KR_API_KEY"):
service_key = resolve_data_go_service_key(args.service_key)
payload = search_food_safety(
args.query,
service_key=service_key,
foodsafety_api_key=args.foodsafetykorea_key,
sample_recalls=args.sample_recalls,
limit=args.limit,
)
payload = search_food_safety(args.query, limit=args.limit, base_url=args.proxy_base_url)
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0
except ValueError as error:
except (ValueError, ApiError) as error:
print(json.dumps({"error": str(error)}, ensure_ascii=False, indent=2), file=sys.stderr)
return 1

View file

@ -12,6 +12,8 @@
- `GET /v1/household-waste/info` — 생활쓰레기 배출정보(`DATA_GO_KR_API_KEY`; `pageNo=1`, `numOfRows=100` 필수)
- `GET /v1/neis/school-search` — 나이스 학교기본정보(교육청명·학교명 검색)
- `GET /v1/neis/school-meal` — 나이스 급식식단정보(일자별 메뉴)
- `GET /v1/mfds/drug-safety/lookup` — 식약처 의약품개요정보(e약은요) + 안전상비의약품 정보(`DATA_GO_KR_API_KEY`)
- `GET /v1/mfds/food-safety/search` — 식약처 부적합 식품 + 식품안전나라 회수 정보(`DATA_GO_KR_API_KEY`, 선택적 `FOODSAFETYKOREA_API_KEY`)
- `GET /v1/korean-stock/search`
- `GET /v1/korean-stock/base-info`
- `GET /v1/korean-stock/trade-info`
@ -23,13 +25,14 @@
- `SEOUL_OPEN_API_KEY` — 프록시 서버 쪽 서울 열린데이터 광장 upstream key
- `HRFCO_OPEN_API_KEY` — 프록시 서버 쪽 한강홍수통제소 upstream key
- `KEDU_INFO_KEY` — 프록시 서버 쪽 나이스(NEIS) 교육정보 개방 포털 Open API 인증키 (`school-search`, `school-meal`)
- `FOODSAFETYKOREA_API_KEY` — 프록시 서버 쪽 식품안전나라 회수정보 live key (`mfds/food-safety/search`; 없으면 sample feed fallback)
- `KRX_API_KEY` — 프록시 서버 쪽 KRX Open API upstream key
- `KSKILL_PROXY_HOST` — 기본 `127.0.0.1`
- `KSKILL_PROXY_PORT` — 기본 `4020`
- `KSKILL_PROXY_CACHE_TTL_MS` — 기본 `300000`
- `KSKILL_PROXY_RATE_LIMIT_WINDOW_MS` — 기본 `60000`
- `KSKILL_PROXY_RATE_LIMIT_MAX` — 기본 `60`
- `DATA_GO_KR_API_KEY` - 공공데이터포털 에서 쓰이는 API 인증키 (`household-waste`, `mfds-drug-safety`)
- `DATA_GO_KR_API_KEY` - 공공데이터포털 에서 쓰이는 API 인증키 (`household-waste`, `real-estate`, `mfds-drug-safety`, `mfds-food-safety`)
기본 정책은 **무료 API 공개 프록시 = 무인증** 이다. 대신 endpoint scope 를 좁게 유지하고, cache + rate limit 으로 남용을 늦춘다.
@ -91,6 +94,23 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/household-waste/info' \
--data-urlencode 'numOfRows=100'
```
의약품 안전 체크 예시 (`DATA_GO_KR_API_KEY` 필요):
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/mfds/drug-safety/lookup' \
--data-urlencode 'itemName=타이레놀' \
--data-urlencode 'itemName=판콜' \
--data-urlencode 'limit=5'
```
식품 안전 체크 예시 (`DATA_GO_KR_API_KEY` 필요, `FOODSAFETYKOREA_API_KEY` 없으면 회수 정보는 sample fallback):
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/mfds/food-safety/search' \
--data-urlencode 'query=김밥' \
--data-urlencode 'limit=5'
```
한국 주식 검색 예시:
```bash

View file

@ -9,7 +9,7 @@
"node": ">=18"
},
"scripts": {
"lint": "node --check src/airkorea.js && node --check src/bluer.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/molit.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/molit.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
"lint": "node --check src/airkorea.js && node --check src/bluer.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/mfds.js && node --check src/molit.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/molit.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
"test": "node --test"
},
"dependencies": {

View file

@ -0,0 +1,336 @@
const DATA_GO_KR_UPSTREAM_BASE_URL = "https://apis.data.go.kr";
const DRUG_EASY_ENDPOINT = `${DATA_GO_KR_UPSTREAM_BASE_URL}/1471000/DrbEasyDrugInfoService/getDrbEasyDrugList`;
const SAFE_STAD_ENDPOINT = `${DATA_GO_KR_UPSTREAM_BASE_URL}/1471000/SafeStadDrugService/getSafeStadDrugInq`;
const IMPROPER_FOOD_ENDPOINT = `${DATA_GO_KR_UPSTREAM_BASE_URL}/1471000/PrsecImproptFoodInfoService03/getPrsecImproptFoodList01`;
const FOOD_RECALL_SAMPLE_URL = "https://openapi.foodsafetykorea.go.kr/api/sample/I0490/json/{start}/{end}";
const FOOD_RECALL_LIVE_URL = "https://openapi.foodsafetykorea.go.kr/api/{apiKey}/I0490/json/{start}/{end}";
class ProxyError extends Error {
constructor(message, { code = "proxy_error", statusCode = 502 } = {}) {
super(message);
this.code = code;
this.statusCode = statusCode;
}
}
function trimOrNull(value) {
if (value === undefined || value === null) {
return null;
}
const trimmed = String(value).trim();
return trimmed ? trimmed : null;
}
function summarizeText(value) {
if (value === undefined || value === null) {
return "";
}
return String(value)
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function parsePositiveInteger(value, fallback, { fieldName, min = 1, max = 50 } = {}) {
if (value === undefined || value === null || value === "") {
return fallback;
}
const parsed = Number.parseInt(String(value), 10);
if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
throw new Error(`${fieldName} must be an integer between ${min} and ${max}.`);
}
return parsed;
}
function normalizeStringList(value) {
if (Array.isArray(value)) {
return value.flatMap((item) => normalizeStringList(item));
}
const trimmed = trimOrNull(value);
return trimmed ? [trimmed] : [];
}
function normalizeMfdsDrugLookupQuery(query) {
const itemNames = [
...normalizeStringList(query.itemName),
...normalizeStringList(query.item_name)
];
const uniqueItemNames = [...new Set(itemNames)];
if (uniqueItemNames.length === 0) {
throw new Error("Provide at least one itemName.");
}
return {
itemNames: uniqueItemNames,
limit: parsePositiveInteger(query.limit, 5, { fieldName: "limit", min: 1, max: 20 })
};
}
function normalizeMfdsFoodSafetyQuery(query) {
const q = trimOrNull(query.query ?? query.q);
if (!q) {
throw new Error("Provide query.");
}
return {
query: q,
limit: parsePositiveInteger(query.limit, 10, { fieldName: "limit", min: 1, max: 20 })
};
}
async function requestJson(url, { params, fetchImpl = global.fetch } = {}) {
if (typeof fetchImpl !== "function") {
throw new ProxyError("A fetch implementation is required.");
}
const requestUrl = new URL(url);
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== "") {
requestUrl.searchParams.set(key, String(value));
}
}
}
let response;
try {
response = await fetchImpl(requestUrl.toString(), {
headers: {
accept: "application/json",
"user-agent": "k-skill-proxy/1.0"
}
});
} catch (error) {
throw new ProxyError(error.message);
}
const text = await response.text();
if (!response.ok) {
throw new ProxyError(`Upstream responded with ${response.status}`, {
code: "upstream_error"
});
}
try {
return JSON.parse(text);
} catch (error) {
if (requestUrl.hostname === "openapi.foodsafetykorea.go.kr") {
throw new ProxyError(
"FoodSafetyKorea response was not valid JSON. Check FOODSAFETYKOREA_API_KEY on the proxy server.",
{ code: "upstream_invalid_response" }
);
}
const contentType = response.headers.get("content-type") || "unknown";
throw new ProxyError(`Upstream response was not valid JSON (content-type: ${contentType}).`, {
code: "upstream_invalid_response"
});
}
}
function extractDataGoItems(payload) {
const raw = payload?.body?.items?.item;
if (Array.isArray(raw)) {
return raw.filter((item) => item && typeof item === "object");
}
if (raw && typeof raw === "object") {
return [raw];
}
return [];
}
function extractFoodRecallRows(payload) {
const raw = payload?.I0490?.row;
if (Array.isArray(raw)) {
return raw.filter((item) => item && typeof item === "object");
}
if (raw && typeof raw === "object") {
return [raw];
}
return [];
}
function normalizeEasyDrugItem(item) {
return {
source: "drug_easy_info",
item_name: summarizeText(item.itemName),
company_name: summarizeText(item.entpName),
efficacy: summarizeText(item.efcyQesitm),
how_to_use: summarizeText(item.useMethodQesitm),
warnings: summarizeText(item.atpnWarnQesitm),
cautions: summarizeText(item.atpnQesitm),
interactions: summarizeText(item.intrcQesitm),
side_effects: summarizeText(item.seQesitm),
storage: summarizeText(item.depositMethodQesitm),
item_seq: summarizeText(item.itemSeq)
};
}
function normalizeSafeStandbyDrugItem(item) {
return {
source: "safe_standby_medicine",
item_name: summarizeText(item.PRDLST_NM),
company_name: summarizeText(item.BSSH_NM),
efficacy: summarizeText(item.EFCY_QESITM),
how_to_use: summarizeText(item.USE_METHOD_QESITM),
warnings: summarizeText(item.ATPN_WARN_QESITM),
cautions: summarizeText(item.ATPN_QESITM),
interactions: summarizeText(item.INTRC_QESITM),
side_effects: summarizeText(item.SE_QESITM)
};
}
function normalizeImproperFoodItem(item) {
const reasonParts = [summarizeText(item.IMPROPT_ITM), summarizeText(item.INSPCT_RESULT)].filter(Boolean);
return {
source: "mfds_improper_food",
product_name: summarizeText(item.PRDUCT),
company_name: summarizeText(item.ENTRPS),
reason: reasonParts.join("; "),
created_at: summarizeText(item.REGIST_DT),
category: summarizeText(item.FOOD_TY)
};
}
function normalizeFoodRecallRow(item) {
return {
source: "foodsafetykorea_recall",
product_name: summarizeText(item.PRDLST_NM || item.PRDTNM),
company_name: summarizeText(item.BSSH_NM || item.BSSHNM),
reason: summarizeText(item.RTRVLPRVNS),
created_at: summarizeText(item.CRET_DTM),
distribution_deadline: summarizeText(item.DISTBTMLMT),
category: summarizeText(item.PRDLST_TYPE || item.PRDLST_CD_NM)
};
}
function filterFoodSafetyItems(items, query) {
const needle = query.trim().toLowerCase();
if (!needle) {
return items;
}
const match = (value) => summarizeText(value).toLowerCase().includes(needle);
const productMatches = items.filter((item) => match(item.product_name));
if (productMatches.length > 0) {
return productMatches;
}
const companyMatches = items.filter((item) => match(item.company_name));
if (companyMatches.length > 0) {
return companyMatches;
}
return items.filter((item) => match(item.reason));
}
async function fetchMfdsDrugLookup({ itemNames, limit, dataGoKrApiKey, fetchImpl = global.fetch }) {
const items = [];
for (const itemName of itemNames) {
const [easyPayload, safePayload] = await Promise.all([
requestJson(DRUG_EASY_ENDPOINT, {
fetchImpl,
params: {
ServiceKey: dataGoKrApiKey,
pageNo: 1,
numOfRows: limit,
type: "json",
itemName
}
}),
requestJson(SAFE_STAD_ENDPOINT, {
fetchImpl,
params: {
serviceKey: dataGoKrApiKey,
pageNo: 1,
numOfRows: limit,
type: "json",
PRDLST_NM: itemName
}
})
]);
items.push(...extractDataGoItems(easyPayload).map(normalizeEasyDrugItem));
items.push(...extractDataGoItems(safePayload).map(normalizeSafeStandbyDrugItem));
}
return {
query: {
item_names: itemNames,
limit
},
items,
note: "상호작용 문구는 공식 품목 안내를 그대로 요약한 참고 정보이며, 복용 가능 여부의 최종 판단은 약사·의료진 확인이 필요합니다."
};
}
async function fetchMfdsFoodSafetySearch({
query,
limit,
dataGoKrApiKey,
foodsafetyKoreaApiKey,
fetchImpl = global.fetch
}) {
const warnings = [];
const items = [];
if (dataGoKrApiKey) {
try {
const improperPayload = await requestJson(IMPROPER_FOOD_ENDPOINT, {
fetchImpl,
params: {
ServiceKey: dataGoKrApiKey,
pageNo: 1,
numOfRows: Math.max(limit * 5, 50),
type: "json"
}
});
items.push(...extractDataGoItems(improperPayload).map(normalizeImproperFoodItem));
} catch (error) {
warnings.push(error.message);
}
} else {
warnings.push("DATA_GO_KR_API_KEY is not configured on the proxy server, so improper-food live lookups were skipped.");
}
const recallUrl = foodsafetyKoreaApiKey
? FOOD_RECALL_LIVE_URL.replace("{apiKey}", encodeURIComponent(foodsafetyKoreaApiKey))
: FOOD_RECALL_SAMPLE_URL;
try {
const recallPayload = await requestJson(
recallUrl
.replace("{start}", "1")
.replace("{end}", String(Math.max(limit * 5, 50))),
{ fetchImpl }
);
items.push(...extractFoodRecallRows(recallPayload).map(normalizeFoodRecallRow));
if (!foodsafetyKoreaApiKey) {
warnings.push("FOODSAFETYKOREA_API_KEY is not configured on the proxy server, so recall results use the public sample feed.");
}
} catch (error) {
warnings.push(error.message);
}
return {
query,
items: filterFoodSafetyItems(items, query).slice(0, limit),
warnings,
note: "이 결과는 공식 회수·부적합 공개 목록 기반 참고 정보이며, 섭취 가능 여부의 최종 판단은 증상 인터뷰와 의료진 상담이 우선입니다."
};
}
module.exports = {
fetchMfdsDrugLookup,
fetchMfdsFoodSafetySearch,
normalizeMfdsDrugLookupQuery,
normalizeMfdsFoodSafetyQuery
};

View file

@ -4,6 +4,12 @@ const { fetchFineDustReport } = require("./airkorea");
const { proxyBlueRibbonNearbyRequest } = require("./bluer");
const { fetchWaterLevelReport } = require("./hrfco");
const { KRX_MARKETS, fetchBaseInfo, fetchTradeInfo, getCurrentKstDate, searchStocks } = require("./krx-stock");
const {
fetchMfdsDrugLookup,
fetchMfdsFoodSafetySearch,
normalizeMfdsDrugLookupQuery,
normalizeMfdsFoodSafetyQuery
} = require("./mfds");
const { fetchTransactions, VALID_ASSET_TYPES, VALID_DEAL_TYPES } = require("./molit");
const { searchRegionCode } = require("./region-lookup");
const { resolveEducationOfficeFromNaturalLanguage } = require("./neis-office-codes");
@ -138,6 +144,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),
foodsafetyKoreaApiKey: trimOrNull(env.FOODSAFETYKOREA_API_KEY),
keduInfoKey: trimOrNull(env.KEDU_INFO_KEY),
krxApiKey: trimOrNull(env.KRX_API_KEY),
cacheTtlMs: parseInteger(env.KSKILL_PROXY_CACHE_TTL_MS, 300000),
@ -936,6 +943,7 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
hrfcoConfigured: Boolean(config.hrfcoApiKey),
opinetConfigured: Boolean(config.opinetApiKey),
molitConfigured: Boolean(config.molitApiKey),
foodsafetyKoreaConfigured: Boolean(config.foodsafetyKoreaApiKey),
neisSchoolMealConfigured: Boolean(config.keduInfoKey),
krxConfigured: Boolean(config.krxApiKey)
},
@ -1643,6 +1651,142 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
cache.set(cacheKey, payload, config.cacheTtlMs);
return payload;
});
app.get("/v1/mfds/drug-safety/lookup", async (request, reply) => {
let normalized;
try {
normalized = normalizeMfdsDrugLookupQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "mfds-drug-safety-lookup",
...normalized
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
if (!config.molitApiKey) {
reply.code(503);
return {
error: "upstream_not_configured",
message: "DATA_GO_KR_API_KEY is not configured on the proxy server.",
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
}
let payload;
try {
payload = await fetchMfdsDrugLookup({
itemNames: normalized.itemNames,
limit: normalized.limit,
dataGoKrApiKey: config.molitApiKey
});
} catch (error) {
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
return {
error: error.code || "proxy_error",
message: error.message
};
}
payload.proxy = {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
};
cache.set(cacheKey, payload, config.cacheTtlMs);
return payload;
});
app.get("/v1/mfds/food-safety/search", async (request, reply) => {
let normalized;
try {
normalized = normalizeMfdsFoodSafetyQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "mfds-food-safety-search",
...normalized,
hasImproperFoodKey: Boolean(config.molitApiKey),
hasRecallKey: Boolean(config.foodsafetyKoreaApiKey)
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
let payload;
try {
payload = await fetchMfdsFoodSafetySearch({
query: normalized.query,
limit: normalized.limit,
dataGoKrApiKey: config.molitApiKey,
foodsafetyKoreaApiKey: config.foodsafetyKoreaApiKey
});
} catch (error) {
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
return {
error: error.code || "proxy_error",
message: error.message
};
}
payload.proxy = {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
};
cache.set(cacheKey, payload, config.cacheTtlMs);
return payload;
});
app.get("/v1/korean-stock/search", async (request, reply) => {
let normalized;

View file

@ -1556,6 +1556,23 @@ test("health endpoint reports molitConfigured status", async (t) => {
assert.equal(response.json().upstreams.molitConfigured, true);
});
test("health endpoint reports foodsafetyKoreaConfigured when FOODSAFETYKOREA_API_KEY is set", async (t) => {
const app = buildServer({
env: { FOODSAFETYKOREA_API_KEY: "food-key" }
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/health"
});
assert.equal(response.json().upstreams.foodsafetyKoreaConfigured, true);
});
const SAMPLE_NEIS_MEAL_JSON = JSON.stringify({
mealServiceDietInfo: [
{
@ -2083,3 +2100,248 @@ test("household waste info endpoint surfaces upstream non-200 as 502", async (t)
assert.equal(response.statusCode, 502);
assert.equal(response.json().error, "upstream_error");
});
test("mfds drug-safety lookup endpoint returns 503 when DATA_GO_KR_API_KEY is missing", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/mfds/drug-safety/lookup?itemName=%ED%83%80%EC%9D%B4%EB%A0%88%EB%86%80"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
});
test("mfds drug-safety lookup endpoint proxies official drug surfaces and caches", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
const text = String(url);
fetchCalls.push(text);
if (text.includes("DrbEasyDrugInfoService/getDrbEasyDrugList")) {
return new Response(
JSON.stringify({
body: {
items: {
item: [
{
itemName: "타이레놀정160밀리그램",
entpName: "한국얀센",
efcyQesitm: "감기로 인한 발열 및 동통에 사용합니다.",
intrcQesitm: "다른 해열진통제와 함께 복용하지 마십시오."
}
]
}
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("SafeStadDrugService/getSafeStadDrugInq")) {
return new Response(
JSON.stringify({
body: {
items: {
item: [
{
PRDLST_NM: "판콜에스내복액",
BSSH_NM: "동화약품",
EFCY_QESITM: "감기 증상 완화",
INTRC_QESITM: "다른 감기약과 병용 주의"
}
]
}
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
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 url =
"/v1/mfds/drug-safety/lookup?itemName=%ED%83%80%EC%9D%B4%EB%A0%88%EB%86%80&itemName=%ED%8C%90%EC%BD%9C&limit=5";
const first = await app.inject({ method: "GET", url });
const second = await app.inject({ method: "GET", url });
assert.equal(first.statusCode, 200);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(fetchCalls.length, 4);
assert.equal(first.json().query.item_names[0], "타이레놀");
assert.equal(first.json().items[0].source, "drug_easy_info");
assert.equal(first.json().items[1].source, "safe_standby_medicine");
assert.ok(fetchCalls.every((entry) => entry.includes("test-key")));
});
test("mfds food-safety search endpoint requires query", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/mfds/food-safety/search"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
});
test("mfds food-safety search endpoint uses sample recall fallback without proxy secrets and caches", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
const text = String(url);
fetchCalls.push(text);
if (text.includes("openapi.foodsafetykorea.go.kr/api/sample/I0490/json/1/50")) {
return new Response(
JSON.stringify({
I0490: {
row: [
{
PRDLST_NM: "맛있는김밥",
BSSH_NM: "예시식품",
RTRVLPRVNS: "대장균 기준 규격 부적합"
}
]
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const url = "/v1/mfds/food-safety/search?query=%EA%B9%80%EB%B0%A5&limit=5";
const first = await app.inject({ method: "GET", url });
const second = await app.inject({ method: "GET", url });
assert.equal(first.statusCode, 200);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(fetchCalls.length, 1);
assert.equal(first.json().items[0].source, "foodsafetykorea_recall");
assert.match(first.json().warnings.join(" "), /sample feed/);
});
test("mfds food-safety search endpoint uses live recall key when configured", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
const text = String(url);
fetchCalls.push(text);
if (text.includes("PrsecImproptFoodInfoService03/getPrsecImproptFoodList01")) {
return new Response(
JSON.stringify({
body: {
items: {
item: [
{
PRDUCT: "예시 유부초밥",
ENTRPS: "예시푸드",
IMPROPT_ITM: "황색포도상구균",
INSPCT_RESULT: "기준 부적합"
}
]
}
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("openapi.foodsafetykorea.go.kr/api/live-food-key/I0490/json/1/50")) {
return new Response(
JSON.stringify({
I0490: {
row: [
{
PRDLST_NM: "예시 유부초밥",
BSSH_NM: "예시푸드",
RTRVLPRVNS: "회수 조치"
}
]
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
DATA_GO_KR_API_KEY: "data-go-key",
FOODSAFETYKOREA_API_KEY: "live-food-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/mfds/food-safety/search?query=%EC%9C%A0%EB%B6%80%EC%B4%88%EB%B0%A5&limit=5"
});
assert.equal(response.statusCode, 200);
assert.equal(response.json().items[0].product_name, "예시 유부초밥");
assert.ok(fetchCalls.some((entry) => entry.includes("data-go-key")));
assert.ok(fetchCalls.some((entry) => entry.includes("live-food-key")));
assert.doesNotMatch(response.json().warnings.join(" "), /sample feed/);
});

View file

@ -286,6 +286,9 @@ test("repository docs advertise the MFDS public-health skills and mandatory symp
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const sources = read(path.join("docs", "sources.md"));
const setup = read(path.join("docs", "setup.md"));
const security = read(path.join("docs", "security-and-secrets.md"));
const setupSkill = read(path.join("k-skill-setup", "SKILL.md"));
const drugSkillPath = path.join(repoRoot, "mfds-drug-safety", "SKILL.md");
const foodSkillPath = path.join(repoRoot, "mfds-food-safety", "SKILL.md");
const drugFeaturePath = path.join(repoRoot, "docs", "features", "mfds-drug-safety.md");
@ -297,6 +300,8 @@ test("repository docs advertise the MFDS public-health skills and mandatory symp
assert.ok(fs.existsSync(foodFeaturePath), "expected docs/features/mfds-food-safety.md to exist");
assert.match(readme, /\| 의약품 안전 체크 \|/);
assert.match(readme, /\| 식품 안전 체크 \|/);
assert.match(readme, /\| 의약품 안전 체크 \| .* \| 불필요 \|/);
assert.match(readme, /\| 식품 안전 체크 \| .* \| 불필요 \|/);
assert.match(install, /--skill mfds-drug-safety/);
assert.match(install, /--skill mfds-food-safety/);
assert.match(sources, /15075057\/openapi\.do/);
@ -304,6 +309,11 @@ test("repository docs advertise the MFDS public-health skills and mandatory symp
assert.match(sources, /15056516\/openapi\.do/);
assert.match(sources, /15074318\/openapi\.do/);
assert.match(sources, /foodsafetykorea\.go\.kr\/api\/openApiInfo\.do.*svc_no=I0490/);
for (const doc of [setup, security, setupSkill]) {
assert.match(doc, /의약품 안전 체크|식품 안전 체크/);
assert.match(doc, /FOODSAFETYKOREA_API_KEY|DATA_GO_KR_API_KEY/);
assert.match(doc, /사용자.*불필요|proxy 서버/u);
}
for (const relativePath of [
path.join("mfds-drug-safety", "SKILL.md"),
@ -2100,13 +2110,18 @@ test("MFDS public-health skill docs require interview-first safety flow and offi
const drugFeatureDoc = read(path.join("docs", "features", "mfds-drug-safety.md"));
const foodFeatureDoc = read(path.join("docs", "features", "mfds-food-safety.md"));
const sources = read(path.join("docs", "sources.md"));
const proxyReadme = read(path.join("packages", "k-skill-proxy", "README.md"));
const proxyDoc = read(path.join("docs", "features", "k-skill-proxy.md"));
for (const doc of [drugSkill, drugFeatureDoc]) {
assert.match(doc, /증상.*바로 단정하지 말고.*먼저 되묻/);
assert.match(doc, /호흡곤란|의식저하|심한 발진/);
assert.match(doc, /DrbEasyDrugInfoService\/getDrbEasyDrugList/);
assert.match(doc, /SafeStadDrugService\/getSafeStadDrugInq/);
assert.match(doc, /DATA_GO_KR_API_KEY/);
assert.match(doc, /KSKILL_PROXY_BASE_URL|k-skill-proxy\.nomadamas\.org/);
assert.match(doc, /사용자.*시크릿 없음|사용자 API key 없이/u);
assert.match(doc, /DATA_GO_KR_API_KEY.*프록시 운영 서버/u);
assert.match(doc, /\/v1\/mfds\/drug-safety\/lookup/);
assert.match(doc, /python3 scripts\/mfds_drug_safety\.py/);
}
@ -2115,7 +2130,11 @@ test("MFDS public-health skill docs require interview-first safety flow and offi
assert.match(doc, /혈변|탈수|호흡곤란/);
assert.match(doc, /PrsecImproptFoodInfoService03\/getPrsecImproptFoodList01/);
assert.match(doc, /I0490/);
assert.match(doc, /DATA_GO_KR_API_KEY/);
assert.match(doc, /KSKILL_PROXY_BASE_URL|k-skill-proxy\.nomadamas\.org/);
assert.match(doc, /사용자.*시크릿 없음|사용자 API key 없이/u);
assert.match(doc, /DATA_GO_KR_API_KEY.*프록시 운영 서버/u);
assert.match(doc, /FOODSAFETYKOREA_API_KEY/);
assert.match(doc, /\/v1\/mfds\/food-safety\/search/);
assert.match(doc, /python3 scripts\/mfds_food_safety\.py/);
assert.match(doc, /https:\/\/openapi\.foodsafetykorea\.go\.kr\/api\/sample\/I0490\/json\/1\/5/);
assert.doesNotMatch(doc, /http:\/\/openapi\.foodsafetykorea\.go\.kr/);
@ -2123,6 +2142,11 @@ test("MFDS public-health skill docs require interview-first safety flow and offi
assert.match(sources, /https:\/\/openapi\.foodsafetykorea\.go\.kr\/api\/sample\/I0490\/json\/1\/5/);
assert.doesNotMatch(sources, /http:\/\/openapi\.foodsafetykorea\.go\.kr/);
for (const doc of [proxyReadme, proxyDoc]) {
assert.match(doc, /\/v1\/mfds\/drug-safety\/lookup/);
assert.match(doc, /\/v1\/mfds\/food-safety\/search/);
assert.match(doc, /FOODSAFETYKOREA_API_KEY/);
}
});
test("docs/setup.md and k-skill-setup document hosted household waste proxy flow", () => {
@ -2131,19 +2155,19 @@ test("docs/setup.md and k-skill-setup document hosted household waste proxy flow
assert.match(
setup,
/한국 주식 정보 조회, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회는 기본 hosted proxy를 쓰므로/,
"setup.md intro should list household waste among hosted-proxy features with no user-side key",
/한국 주식 정보 조회, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 기본 hosted proxy를 쓰므로/,
"setup.md intro should list household waste, school lunch, and MFDS skills among hosted-proxy features with no user-side key",
);
assert.match(setup, /DATA_GO_KR_API_KEY.*서버에 설정/);
assert.match(
setup,
/미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path\(`k-skill-proxy\.nomadamas\.org`\)/,
"setup.md should list fine dust, Han River, gas, household waste, and school lunch when KSKILL_PROXY_BASE_URL is unset",
/미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path\(`k-skill-proxy\.nomadamas\.org`\)/,
"setup.md should list fine dust, Han River, gas, household waste, school lunch, and MFDS skills when KSKILL_PROXY_BASE_URL is unset",
);
assert.match(
setupSkill,
/미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회는 `KSKILL_PROXY_BASE_URL`/,
"k-skill-setup SKILL should mirror setup.md hosted-proxy unset-base-URL guidance",
/미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 `KSKILL_PROXY_BASE_URL`/,
"k-skill-setup SKILL should mirror setup.md hosted-proxy unset-base-URL guidance including MFDS skills",
);
assert.match(setup, /\| 생활쓰레기 배출정보 조회 \|/);
@ -2160,17 +2184,17 @@ test("docs/setup.md and k-skill-setup document hosted school lunch proxy flow",
const setup = read(path.join("docs", "setup.md"));
const setupSkill = read(path.join("k-skill-setup", "SKILL.md"));
const examplesSecrets = read(path.join("examples", "secrets.env.example"));
assert.match(setup, /학교 급식 식단 조회는 기본 hosted proxy/);
assert.match(setup, /학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 기본 hosted proxy/);
assert.match(setup, /KEDU_INFO_KEY.*서버에 설정/);
assert.match(
setup,
/미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path\(`k-skill-proxy\.nomadamas\.org`\)/,
"setup.md should list fine dust, Han River, gas, household waste, and school lunch when KSKILL_PROXY_BASE_URL is unset",
/미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path\(`k-skill-proxy\.nomadamas\.org`\)/,
"setup.md should list fine dust, Han River, gas, household waste, school lunch, and MFDS skills when KSKILL_PROXY_BASE_URL is unset",
);
assert.match(
setupSkill,
/미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회는 `KSKILL_PROXY_BASE_URL`/,
"k-skill-setup SKILL should mirror setup.md hosted-proxy unset-base-URL guidance",
/미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 `KSKILL_PROXY_BASE_URL`/,
"k-skill-setup SKILL should mirror setup.md hosted-proxy unset-base-URL guidance including MFDS skills",
);
assert.match(setup, /\| 학교 급식 식단 조회 \|/);

View file

@ -2,9 +2,10 @@ import unittest
from scripts.mfds_drug_safety import (
build_drug_interview,
lookup_drugs,
normalize_easy_drug_item,
normalize_safe_stad_item,
resolve_service_key,
resolve_proxy_base_url,
)
@ -29,15 +30,15 @@ class DrugNormalizationTest(unittest.TestCase):
def test_normalize_easy_drug_item_extracts_public_safety_summary(self):
item = normalize_easy_drug_item(
{
"itemName": "타이레놀정160밀리그램",
"entpName": "한국얀센",
"efcyQesitm": "감기로 인한 발열 및 동통에 사용합니다.",
"useMethodQesitm": "만 12세 이상은 필요시 복용합니다.",
"atpnWarnQesitm": "매일 세 잔 이상 술을 마시는 사람은 전문가와 상의하십시오.",
"atpnQesitm": "간질환 환자는 주의하십시오.",
"intrcQesitm": "다른 해열진통제와 함께 복용하지 마십시오.",
"seQesitm": "발진, 구역이 나타날 수 있습니다.",
"depositMethodQesitm": "실온 보관하십시오.",
"item_name": "타이레놀정160밀리그램",
"company_name": "한국얀센",
"efficacy": "감기로 인한 발열 및 동통에 사용합니다.",
"how_to_use": "만 12세 이상은 필요시 복용합니다.",
"warnings": "매일 세 잔 이상 술을 마시는 사람은 전문가와 상의하십시오.",
"cautions": "간질환 환자는 주의하십시오.",
"interactions": "다른 해열진통제와 함께 복용하지 마십시오.",
"side_effects": "발진, 구역이 나타날 수 있습니다.",
"storage": "실온 보관하십시오.",
}
)
@ -51,13 +52,13 @@ class DrugNormalizationTest(unittest.TestCase):
def test_normalize_safe_stad_item_extracts_store_medicine_fields(self):
item = normalize_safe_stad_item(
{
"PRDLST_NM": "어린이타이레놀현탁액",
"BSSH_NM": "한국존슨앤드존슨판매(유)",
"EFCY_QESITM": "해열 및 진통",
"USE_METHOD_QESITM": "용법에 따라 복용",
"ATPN_WARN_QESITM": "과량복용 주의",
"INTRC_QESITM": "다른 아세트아미노펜 제제와 병용 주의",
"SE_QESITM": "드물게 발진",
"item_name": "어린이타이레놀현탁액",
"company_name": "한국존슨앤드존슨판매(유)",
"efficacy": "해열 및 진통",
"how_to_use": "용법에 따라 복용",
"warnings": "과량복용 주의",
"interactions": "다른 아세트아미노펜 제제와 병용 주의",
"side_effects": "드물게 발진",
}
)
@ -66,13 +67,27 @@ class DrugNormalizationTest(unittest.TestCase):
self.assertIn("아세트아미노펜", item["interactions"])
class ServiceKeyResolutionTest(unittest.TestCase):
def test_resolve_service_key_requires_data_go_kr_api_key(self):
with self.assertRaisesRegex(ValueError, "DATA_GO_KR_API_KEY"):
resolve_service_key(None, env={})
class ProxyResolutionTest(unittest.TestCase):
def test_resolve_proxy_base_url_defaults_to_hosted_proxy(self):
self.assertEqual(resolve_proxy_base_url(None, env={}), "https://k-skill-proxy.nomadamas.org")
self.assertEqual(resolve_proxy_base_url(None, env={"KSKILL_PROXY_BASE_URL": "https://proxy.example.com/"}), "https://proxy.example.com")
with self.assertRaisesRegex(ValueError, "KSKILL_PROXY_BASE_URL"):
resolve_proxy_base_url(None, env={"KSKILL_PROXY_BASE_URL": "off"})
self.assertEqual(resolve_service_key("abc", env={}), "abc")
self.assertEqual(resolve_service_key(None, env={"DATA_GO_KR_API_KEY": "xyz"}), "xyz")
def test_lookup_drugs_uses_proxy_route(self):
captured = {}
def fake_request_json(request):
captured["url"] = request.full_url
return {"items": []}
payload = lookup_drugs(["타이레놀", "판콜"], limit=3, base_url="https://proxy.example.com", request_json=fake_request_json)
self.assertEqual(payload, {"items": []})
self.assertIn("https://proxy.example.com/v1/mfds/drug-safety/lookup", captured["url"])
self.assertIn("itemName=%ED%83%80%EC%9D%B4%EB%A0%88%EB%86%80", captured["url"])
self.assertIn("itemName=%ED%8C%90%EC%BD%9C", captured["url"])
self.assertIn("limit=3", captured["url"])
if __name__ == "__main__":

View file

@ -1,16 +1,12 @@
import unittest
from unittest import mock
from scripts.mfds_food_safety import (
ApiError,
FOOD_RECALL_LIVE_URL,
FOOD_RECALL_SAMPLE_URL,
_request_json,
build_food_interview,
filter_food_items,
normalize_food_recall_row,
normalize_improper_food_item,
resolve_data_go_service_key,
resolve_proxy_base_url,
search_food_safety,
)
@ -82,38 +78,26 @@ class FoodNormalizationTest(unittest.TestCase):
self.assertEqual(by_company[0]["company_name"], "김밥나라")
class FoodServiceKeyResolutionTest(unittest.TestCase):
def test_resolve_data_go_service_key_requires_data_go_kr_api_key(self):
with self.assertRaisesRegex(ValueError, "DATA_GO_KR_API_KEY"):
resolve_data_go_service_key(None, env={})
class ProxyResolutionTest(unittest.TestCase):
def test_resolve_proxy_base_url_defaults_to_hosted_proxy(self):
self.assertEqual(resolve_proxy_base_url(None, env={}), "https://k-skill-proxy.nomadamas.org")
self.assertEqual(resolve_proxy_base_url(None, env={"KSKILL_PROXY_BASE_URL": "https://proxy.example.com/"}), "https://proxy.example.com")
with self.assertRaisesRegex(ValueError, "KSKILL_PROXY_BASE_URL"):
resolve_proxy_base_url(None, env={"KSKILL_PROXY_BASE_URL": "off"})
self.assertEqual(resolve_data_go_service_key("abc", env={}), "abc")
self.assertEqual(resolve_data_go_service_key(None, env={"DATA_GO_KR_API_KEY": "xyz"}), "xyz")
def test_search_food_safety_uses_proxy_route(self):
captured = {}
def fake_request_json(request):
captured["url"] = request.full_url
return {"items": [], "warnings": []}
class FoodRecallTransportTest(unittest.TestCase):
def test_food_recall_urls_use_https(self):
self.assertTrue(FOOD_RECALL_SAMPLE_URL.startswith("https://"))
self.assertTrue(FOOD_RECALL_LIVE_URL.startswith("https://"))
payload = search_food_safety("김밥", limit=4, base_url="https://proxy.example.com", request_json=fake_request_json)
def test_request_json_turns_invalid_foodsafety_key_html_into_api_error(self):
class FakeResponse:
headers = {"Content-Type": "text/html;charset=utf-8"}
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return "<html><script>alert('invalid key');</script></html>".encode("utf-8")
url = FOOD_RECALL_LIVE_URL.format(api_key="invalid-demo-key", start=1, end=1)
with mock.patch("scripts.mfds_food_safety.urllib.request.urlopen", return_value=FakeResponse()):
with self.assertRaisesRegex(ApiError, "foodsafetykorea-key"):
_request_json(url)
self.assertEqual(payload, {"items": [], "warnings": []})
self.assertIn("https://proxy.example.com/v1/mfds/food-safety/search", captured["url"])
self.assertIn("query=%EA%B9%80%EB%B0%A5", captured["url"])
self.assertIn("limit=4", captured["url"])
if __name__ == "__main__":