Merge dev into feature/#58: sync korea-weather from PR #69

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-07 04:47:36 +09:00
commit 7a2ea259b0
14 changed files with 740 additions and 15 deletions

View file

@ -22,6 +22,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| KTX 예매 | Dynapath anti-bot 대응 helper 로 KTX/Korail 열차 조회, 예약, 예약 확인, 취소 | 필요 | [KTX 예매 가이드](docs/features/ktx-booking.md) |
| 카카오톡 Mac CLI | macOS에서 kakaocli로 대화 조회, 검색, 테스트 전송, 확인 후 실제 전송 | 불필요 | [카카오톡 Mac CLI 가이드](docs/features/kakaotalk-mac.md) |
| 서울 지하철 도착정보 조회 | self-host 또는 배포 확인이 끝난 `k-skill-proxy` 경유로 역 기준 실시간 도착 예정 열차 확인 | 프록시 URL 필요 | [서울 지하철 도착정보 가이드](docs/features/seoul-subway-arrival.md) |
| 한국 날씨 조회 | self-host 또는 배포 확인이 끝난 `k-skill-proxy` 경유로 기상청 단기예보 확인 | 프록시 URL 필요 | [한국 날씨 조회 가이드](docs/features/korea-weather.md) |
| 사용자 위치 미세먼지 조회 | `k-skill-proxy` 로 현재 위치 또는 지역 fallback 기준 PM10/PM2.5 확인 | 불필요 | [사용자 위치 미세먼지 조회 가이드](docs/features/fine-dust-location.md) |
| 한강 수위 정보 조회 | self-host 또는 배포 확인이 끝난 `k-skill-proxy` 경유로 관측소 기준 현재 수위·유량·기준수위 확인 | 프록시 URL 필요 | [한강 수위 정보 가이드](docs/features/han-river-water-level.md) |
| 한국 법령 검색 | `korean-law-mcp` 우선 + 장애 시 `법망` fallback으로 법령/조문/판례/유권해석 조회 | 로컬 CLI/MCP면 `LAW_OC` 필요, remote endpoint/법망 fallback은 불필요 | [한국 법령 검색 가이드](docs/features/korean-law-search.md) |
@ -74,6 +75,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [KTX 예매](docs/features/ktx-booking.md)
- [카카오톡 Mac CLI](docs/features/kakaotalk-mac.md)
- [서울 지하철 도착정보 조회](docs/features/seoul-subway-arrival.md)
- [한국 날씨 조회 가이드](docs/features/korea-weather.md)
- [사용자 위치 미세먼지 조회](docs/features/fine-dust-location.md)
- [한강 수위 정보 가이드](docs/features/han-river-water-level.md)
- [한국 법령 검색 가이드](docs/features/korean-law-search.md)

View file

@ -12,10 +12,11 @@
client/skill -> k-skill-proxy -> upstream public API
```
현재 기본 엔드포인트는 아래와 같습니다.
현재 기본 엔드포인트는 아래 다섯 가지입니다.
- `GET /health`
- `GET /v1/fine-dust/report`
- `GET /v1/korea-weather/forecast`
- `GET /v1/seoul-subway/arrival`
- `GET /v1/han-river/water-level`
- `GET /v1/korean-stock/search`
@ -34,6 +35,7 @@ client/skill -> k-skill-proxy -> upstream public API
프록시 서버 쪽:
- `AIR_KOREA_OPEN_API_KEY=...`
- `KMA_OPEN_API_KEY=...`
- `SEOUL_OPEN_API_KEY=...`
- `HRFCO_OPEN_API_KEY=...`
- `OPINET_API_KEY=...`
@ -100,6 +102,14 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/seoul-subway/arrival' \
--data-urlencode 'stationName=강남'
```
한국 날씨 endpoint:
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/korea-weather/forecast' \
--data-urlencode 'lat=37.5665' \
--data-urlencode 'lon=126.9780'
```
한강 수위 정보 endpoint:
```bash
@ -161,4 +171,5 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/B552584/ArpltnInforInqireSv
- upstream key는 프록시 서버에서만 관리합니다.
- 한국 주식 route도 사용자에게 `KRX_API_KEY` 를 배포하지 않습니다.
- client 쪽에는 upstream API key를 배포하지 않습니다.
- public hosted route rollout 이 끝나기 전에는 서울 지하철 예시를 local/self-host URL 로 검증합니다.
- public hosted route rollout 이 끝나기 전에는 서울 지하철/한국 날씨 예시를 local/self-host URL 로 검증합니다.
- public hosted route rollout 이 끝나기 전에는 한강 수위 route도 local/self-host 또는 배포 확인이 끝난 proxy URL 로 검증합니다.

View file

@ -0,0 +1,70 @@
# 한국 날씨 조회 가이드
## 이 기능으로 할 수 있는 일
- 한국 기상청 단기예보 조회서비스를 proxy 경유로 호출
- `nx` / `ny` 격자 또는 `lat` / `lon` 기준 단기예보 확인
- 개인 OpenAPI key 없이 `TMP`, `SKY`, `PTY`, `POP` 같은 핵심 날씨 category 요약
## 먼저 필요한 것
- [공통 설정 가이드](../setup.md) 완료
- [보안/시크릿 정책](../security-and-secrets.md) 확인
- self-host 또는 배포 확인이 끝난 proxy base URL: `KSKILL_PROXY_BASE_URL`
## 필요한 환경변수
- `KSKILL_PROXY_BASE_URL` (필수: self-host 또는 배포 확인이 끝난 proxy base URL)
사용자가 공공데이터포털 기상청 단기예보 API key를 직접 발급할 필요는 없다. 대신 `KSKILL_PROXY_BASE_URL``/v1/korea-weather/forecast` route가 실제로 배포된 proxy 를 가리켜야 한다. upstream `KMA_OPEN_API_KEY` 는 proxy 서버에서만 관리한다.
## 입력값
- 격자 좌표 `nx`, `ny`
- 또는 위도/경도 `lat`, `lon`
- 선택 사항: `baseDate`, `baseTime`, `pageNo`, `numOfRows`
## 기본 흐름
1. `KSKILL_PROXY_BASE_URL` 로 self-host 또는 배포 확인이 끝난 proxy base URL 을 확인한다.
2. `/v1/korea-weather/forecast` 로 한국 기상청 단기예보를 조회한다.
3. `baseDate` / `baseTime` 을 생략하면 proxy 가 KST 기준 최신 발표 시각을 자동으로 선택한다.
4. 응답의 `item[]` 에서 `TMP`, `SKY`, `PTY`, `POP`, `PCP`, `SNO`, `REH`, `WSD` 를 우선 요약한다.
## 예시
위도/경도 기준:
```bash
curl -fsS --get 'https://your-proxy.example.com/v1/korea-weather/forecast' \
--data-urlencode 'lat=37.5665' \
--data-urlencode 'lon=126.9780'
```
격자 좌표 기준:
```bash
curl -fsS --get 'https://your-proxy.example.com/v1/korea-weather/forecast' \
--data-urlencode 'nx=60' \
--data-urlencode 'ny=127' \
--data-urlencode 'baseDate=20260405' \
--data-urlencode 'baseTime=0500'
```
## Category 메모
- `TMP`: 기온(℃)
- `SKY`: 하늘상태
- `PTY`: 강수형태
- `POP`: 강수확률(%)
- `PCP`: 강수량
- `SNO`: 적설
- `REH`: 습도(%)
- `WSD`: 풍속(m/s)
## 주의할 점
- 단기예보는 5km 격자 기반이라 행정구역 경계와 완전히 일치하지 않을 수 있다.
- 발표 시각 직후에는 최신 `baseTime` 이 아직 준비되지 않았을 수 있다. proxy 는 보수적으로 직전 발표 시각을 선택한다.
- public hosted route rollout 이 끝나기 전까지는 `KSKILL_PROXY_BASE_URL` 을 반드시 명시한다.
- self-host proxy 설정은 [k-skill 프록시 서버 가이드](k-skill-proxy.md)를 본다.

View file

@ -56,6 +56,7 @@ npx --yes skills add <owner/repo> \
--skill korean-stock-search \
--skill joseon-sillok-search \
--skill korean-patent-search \
--skill korea-weather \
--skill cheap-gas-nearby \
--skill fine-dust-location \
--skill han-river-water-level \
@ -84,6 +85,7 @@ npx --yes skills add <owner/repo> \
--skill joseon-sillok-search \
--skill korean-patent-search \
--skill seoul-subway-arrival \
--skill korea-weather \
--skill fine-dust-location
```
@ -293,6 +295,7 @@ python3 scripts/korean_spell_check.py --text "아버지가방에들어가신다.
- `srt-booking`
- `ktx-booking`
- `seoul-subway-arrival`
- `korea-weather`
- `fine-dust-location`
- `korean-law-search`
- `real-estate-search`

View file

@ -12,6 +12,7 @@
- 토스증권 조회 스킬 출시
- 로또 당첨번호
- 서울 지하철 도착 정보
- 한국 날씨 조회 스킬 출시
- 사용자 위치 미세먼지 조회 스킬 출시
- 한강 수위 정보 조회 스킬 출시
- 한국 법령 검색 스킬 출시
@ -112,10 +113,10 @@
- 장점: 결제/포인트/배송/가격비교를 묶는 쇼핑형 허브 후보가 된다
- 이유: 스마트스토어와 별도의 큰 축으로 키울 수 있다
#### 한국 기상청 날씨/특보
#### 한국 기상청 특보/중기예보 확장
- 장점: 국내 날씨, 특보, 단기 예보를 한국형 생활 정보로 직접 연결하기 좋
- 이유: 미세먼지 스킬과 함께 묶었을 때 생활 밀착도가 더 올라간
- 장점: 이미 선출시한 한국 날씨 조회 스킬에 특보/중기예보를 붙여 생활 정보 깊이를 늘릴 수 있
- 이유: 단기예보 다음 단계로 자연스럽게 확장 가능하
### 기존 탐색 후보

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`)를 쓰므로 사용자 쪽 키가 불필요하다.
서울 지하철 도착정보와 한국 날씨 조회는 hosted public route rollout 이 끝나기 전까지 self-host 또는 배포 확인이 끝난 proxy URL 만 넣는다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다.
## 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`)를 경유하므로 사용자 쪽 키가 불필요하다. 한국 주식 정보 조회도 기본 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`, `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`)를 경유하므로 사용자 쪽 키가 불필요하다. 한국 주식 정보 조회도 기본 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`)를 사용한다.
이 레포의 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를 쓰므로 사용자 쪽 키가 불필요하다.
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, 한국 특허 정보 검색의 KIPRIS Plus 경로용 `KIPRIS_PLUS_API_KEY`, self-host 프록시 운영용 서울 지하철/한국 날씨/미세먼지/한강홍수통제소 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다. 미세먼지, 한강 수위, 주유소 가격, 부동산 실거래가, 한국 주식 정보 조회는 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다.
## Credential resolution order
@ -34,7 +34,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
실제 값을 채운다.
서울 지하철 도착정보는 hosted public route rollout 이 끝나기 전까지 `KSKILL_PROXY_BASE_URL` 을 self-host 또는 배포 확인이 끝난 proxy URL 로 채워야 한다. 미세먼지, 한강 수위, 주유소 가격은 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다.
서울 지하철 도착정보와 한국 날씨 조회는 hosted public route rollout 이 끝나기 전까지 `KSKILL_PROXY_BASE_URL` 을 self-host 또는 배포 확인이 끝난 proxy URL 로 채워야 한다. 미세먼지, 한강 수위, 주유소 가격은 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다.
한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC``korean-law-mcp` 로컬 실행에 쓴다. 로컬 CLI/MCP 경로는 `LAW_OC` 를 채운 뒤 `npm install -g korean-law-mcp``korean-law list` 로 설치 상태를 확인한다.
@ -74,6 +74,7 @@ bash scripts/check-setup.sh
| 한국 주식 정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KRX_API_KEY`) |
| 근처 가장 싼 주유소 찾기 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
| 서울 지하철 도착정보 조회 | self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL` |
| 한국 날씨 조회 | self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL` |
| 사용자 위치 미세먼지 조회 | `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY` |
| 한강 수위 정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
@ -82,6 +83,7 @@ bash scripts/check-setup.sh
- [SRT 예매 가이드](features/srt-booking.md)
- [KTX 예매 가이드](features/ktx-booking.md)
- [서울 지하철 도착정보 가이드](features/seoul-subway-arrival.md)
- [한국 날씨 조회 가이드](features/korea-weather.md)
- [사용자 위치 미세먼지 조회 가이드](features/fine-dust-location.md)
- [한강 수위 정보 가이드](features/han-river-water-level.md)
- [한국 법령 검색 가이드](features/korean-law-search.md)

View file

@ -80,6 +80,7 @@
- Opinet 주유소 상세정보 API: https://www.opinet.co.kr/api/detailById.do
- Opinet 지역코드 API: https://www.opinet.co.kr/api/areaCode.do
- 서울특별시 지하철 실시간 도착정보: https://www.data.go.kr/data/15058052/openapi.do
- 기상청 단기예보 조회서비스: https://www.data.go.kr/data/15084084/openapi.do
- 에어코리아 대기오염정보: https://www.data.go.kr/data/15073861/openapi.do
- 에어코리아 측정소정보: https://www.data.go.kr/data/15073877/openapi.do
- 한강홍수통제소 Open API 레퍼런스: https://www.hrfco.go.kr/web/openapiPage/reference.do

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으로 안내한다.
@ -105,6 +105,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
- 한국 주식 정보 조회: 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KRX_API_KEY`)
- 근처 가장 싼 주유소 찾기: 사용자 시크릿 불필요 (기본 hosted proxy 사용)
- 서울 지하철: self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL`
- 한국 날씨: self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL`
- 사용자 위치 미세먼지 조회: `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY`
시크릿이 비어 있다는 이유로 다른 서비스나 비공식 우회 경로를 자동 선택하지 않는다.

101
korea-weather/SKILL.md Normal file
View file

@ -0,0 +1,101 @@
---
name: korea-weather
description: 한국 날씨를 기상청 단기예보 조회서비스와 프록시 경유로 조회해 요약한다.
license: MIT
metadata:
category: weather
locale: ko-KR
phase: v1
---
# Korea Weather
## What this skill does
기상청 단기예보 조회서비스를 `k-skill-proxy` 경유로 조회해서 한국 날씨를 요약한다.
사용자는 개인 OpenAPI key를 직접 발급할 필요가 없고, proxy 서버에만 `KMA_OPEN_API_KEY` 를 둔다.
## When to use
- "서울 시청 근처 지금 날씨 어때?"
- "부산 날씨 알려줘"
- "위도/경도 기준으로 한국 단기예보 보고 싶어"
## Prerequisites
- optional: `jq`
- self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL`
## Required environment variables
- `KSKILL_PROXY_BASE_URL` (필수: self-host 또는 배포 확인이 끝난 proxy base URL)
사용자가 공공데이터포털 기상청 API key를 직접 다룰 필요는 없다. 대신 `/v1/korea-weather/forecast` route가 실제로 올라와 있는 proxy URL 을 `KSKILL_PROXY_BASE_URL` 로 받는다. upstream `KMA_OPEN_API_KEY` 는 proxy 서버에서만 관리한다.
## Inputs
- 격자 좌표: `nx`, `ny`
- 또는 위도/경도: `lat`, `lon`
- 선택 사항: `baseDate`, `baseTime`
`baseDate` / `baseTime` 을 생략하면 proxy 가 KST 기준 최신 단기예보 발표 시각을 자동으로 고른다.
## Workflow
### 1. Resolve the proxy base URL
`KSKILL_PROXY_BASE_URL` 로 self-host 또는 배포 확인이 끝난 proxy base URL 을 확인한다.
### 2. Query the short-term forecast endpoint
격자 좌표가 이미 있으면 그대로 넣고, 위도/경도만 있으면 proxy 에 그대로 넘긴다.
```bash
curl -fsS --get 'https://your-proxy.example.com/v1/korea-weather/forecast' \
--data-urlencode 'lat=37.5665' \
--data-urlencode 'lon=126.9780'
```
격자 좌표 예시:
```bash
curl -fsS --get 'https://your-proxy.example.com/v1/korea-weather/forecast' \
--data-urlencode 'nx=60' \
--data-urlencode 'ny=127' \
--data-urlencode 'baseDate=20260405' \
--data-urlencode 'baseTime=0500'
```
### 3. Summarize the response conservatively
가능하면 아래 항목만 먼저 요약한다.
- `TMP`: 기온
- `SKY`: 하늘상태
- `PTY`: 강수형태
- `POP`: 강수확률
- `PCP`: 강수량
- `SNO`: 적설
- `REH`: 습도
- `WSD`: 풍속
응답에는 조회 시점과 `baseDate` / `baseTime` 도 함께 적는다.
## Done when
- 요청 위치의 단기예보 응답이 정리되어 있다
- 조회 시점과 예보 발표 시각이 명시되어 있다
- upstream key가 클라이언트에 노출되지 않았다
## Failure modes
- `KSKILL_PROXY_BASE_URL` 이 비어 있거나 weather route가 아직 배포되지 않은 경우
- `nx` / `ny` 또는 `lat` / `lon` 이 불완전한 경우
- 기상청 quota 초과 또는 upstream 장애
- 선택한 발표 시각에 아직 예보가 준비되지 않은 경우
## Notes
- 공식 API는 `nx` / `ny` 격자를 쓰지만, proxy 는 `lat` / `lon` 도 받아 내부에서 격자로 변환한다.
- 단기예보 category 는 `TMP`, `SKY`, `PTY`, `POP`, `PCP`, `SNO`, `REH`, `WSD` 등을 중심으로 본다.
- proxy 운영/환경변수 설정은 `docs/features/k-skill-proxy.md` 를 참고한다.

View file

@ -1,11 +1,12 @@
# k-skill-proxy
`k-skill`용 Fastify 기반 프록시 서버입니다. AirKorea 미세먼지 조회, 서울 지하철 실시간 도착정보, 한강홍수통제소 수위 정보를 감싸고, 이후 무료/공공 API adapter를 추가하는 베이스로 씁니다.
`k-skill`용 Fastify 기반 프록시 서버입니다. AirKorea 미세먼지 조회, 기상청 단기예보, 서울 지하철 실시간 도착정보, 한강홍수통제소 수위 정보를 감싸고, 이후 무료/공공 API adapter를 추가하는 베이스로 씁니다.
## 현재 제공 엔드포인트
- `GET /health`
- `GET /v1/fine-dust/report`
- `GET /v1/korea-weather/forecast`
- `GET /v1/seoul-subway/arrival`
- `GET /v1/han-river/water-level`
- `GET /v1/korean-stock/search`
@ -15,6 +16,7 @@
## 환경변수
- `AIR_KOREA_OPEN_API_KEY` — 프록시 서버 쪽 AirKorea upstream key
- `KMA_OPEN_API_KEY` — 프록시 서버 쪽 기상청 단기예보 upstream key
- `SEOUL_OPEN_API_KEY` — 프록시 서버 쪽 서울 열린데이터 광장 upstream key
- `HRFCO_OPEN_API_KEY` — 프록시 서버 쪽 한강홍수통제소 upstream key
- `KRX_API_KEY` — 프록시 서버 쪽 KRX Open API upstream key
@ -41,6 +43,14 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/seoul-subway/arrival' \
--data-urlencode 'stationName=강남'
```
한국 날씨 예시:
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/korea-weather/forecast' \
--data-urlencode 'lat=37.5665' \
--data-urlencode 'lon=126.9780'
```
한강 수위 정보 예시:
```bash

View file

@ -7,7 +7,11 @@ const { KRX_MARKETS, fetchBaseInfo, fetchTradeInfo, getCurrentKstDate, searchSto
const { fetchTransactions, VALID_ASSET_TYPES, VALID_DEAL_TYPES } = require("./molit");
const { searchRegionCode } = require("./region-lookup");
const AIR_KOREA_UPSTREAM_BASE_URL = "http://apis.data.go.kr";
const DATA_GO_KR_UPSTREAM_BASE_URL = "https://apis.data.go.kr";
const SEOUL_OPEN_API_BASE_URL = "http://swopenapi.seoul.go.kr";
const KMA_FORECAST_BASE_TIMES = ["0200", "0500", "0800", "1100", "1400", "1700", "2000", "2300"];
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const KMA_FORECAST_READY_MINUTE = 10;
const OPINET_API_BASE_URL = "https://www.opinet.co.kr/api";
const ALLOWED_AIRKOREA_ROUTES = new Map([
["MsrstnInfoInqireSvc", new Set(["getMsrstnList", "getNearbyMsrstnList", "getTMStdrCrdnt"])],
@ -41,12 +45,90 @@ function trimOrNull(value) {
return trimmed;
}
function padNumber(value, length) {
return String(value).padStart(length, "0");
}
function formatKstDate(date) {
const kstDate = new Date(date.getTime() + KST_OFFSET_MS);
return `${padNumber(kstDate.getUTCFullYear(), 4)}${padNumber(kstDate.getUTCMonth() + 1, 2)}${padNumber(kstDate.getUTCDate(), 2)}`;
}
function resolveLatestKmaForecastBase(now = new Date()) {
const kstDate = new Date(now.getTime() + KST_OFFSET_MS);
const currentMinutes = (kstDate.getUTCHours() * 60) + kstDate.getUTCMinutes();
for (let index = KMA_FORECAST_BASE_TIMES.length - 1; index >= 0; index -= 1) {
const baseTime = KMA_FORECAST_BASE_TIMES[index];
const baseHour = Number.parseInt(baseTime.slice(0, 2), 10);
const baseMinute = Number.parseInt(baseTime.slice(2, 4), 10);
const readyMinutes = (baseHour * 60) + baseMinute + KMA_FORECAST_READY_MINUTE;
if (currentMinutes >= readyMinutes) {
return {
baseDate: formatKstDate(now),
baseTime
};
}
}
return {
baseDate: formatKstDate(new Date(now.getTime() - (24 * 60 * 60 * 1000))),
baseTime: KMA_FORECAST_BASE_TIMES[KMA_FORECAST_BASE_TIMES.length - 1]
};
}
function convertLatLonToKmaGrid(latitude, longitude) {
const RE = 6371.00877;
const GRID = 5.0;
const SLAT1 = 30.0;
const SLAT2 = 60.0;
const OLON = 126.0;
const OLAT = 38.0;
const XO = 43;
const YO = 136;
const DEGRAD = Math.PI / 180.0;
const re = RE / GRID;
const slat1 = SLAT1 * DEGRAD;
const slat2 = SLAT2 * DEGRAD;
const olon = OLON * DEGRAD;
const olat = OLAT * DEGRAD;
let sn = Math.tan((Math.PI * 0.25) + (slat2 * 0.5)) / Math.tan((Math.PI * 0.25) + (slat1 * 0.5));
sn = Math.log(Math.cos(slat1) / Math.cos(slat2)) / Math.log(sn);
let sf = Math.tan((Math.PI * 0.25) + (slat1 * 0.5));
sf = (Math.pow(sf, sn) * Math.cos(slat1)) / sn;
let ro = Math.tan((Math.PI * 0.25) + (olat * 0.5));
ro = (re * sf) / Math.pow(ro, sn);
let ra = Math.tan((Math.PI * 0.25) + ((latitude * DEGRAD) * 0.5));
ra = (re * sf) / Math.pow(ra, sn);
let theta = (longitude * DEGRAD) - olon;
if (theta > Math.PI) {
theta -= 2.0 * Math.PI;
}
if (theta < -Math.PI) {
theta += 2.0 * Math.PI;
}
theta *= sn;
return {
nx: Math.floor((ra * Math.sin(theta)) + XO + 0.5),
ny: Math.floor(ro - (ra * Math.cos(theta)) + YO + 0.5)
};
}
function buildConfig(env = process.env) {
return {
host: env.KSKILL_PROXY_HOST || "127.0.0.1",
port: parseInteger(env.KSKILL_PROXY_PORT, 4020),
proxyName: env.KSKILL_PROXY_NAME || "k-skill-proxy",
airKoreaApiKey: trimOrNull(env.AIR_KOREA_OPEN_API_KEY),
kmaOpenApiKey: trimOrNull(env.KMA_OPEN_API_KEY),
seoulOpenApiKey: trimOrNull(env.SEOUL_OPEN_API_KEY),
hrfcoApiKey: trimOrNull(env.HRFCO_OPEN_API_KEY),
opinetApiKey: trimOrNull(env.OPINET_API_KEY),
@ -153,6 +235,76 @@ function normalizeSeoulSubwayQuery(query) {
};
}
function normalizeKmaForecastQuery(query, now = new Date()) {
const rawNx = parseInteger(query.nx, Number.NaN);
const rawNy = parseInteger(query.ny, Number.NaN);
const latitude = parseFloatValue(query.lat ?? query.latitude);
const longitude = parseFloatValue(query.lon ?? query.longitude ?? query.lng);
const hasGrid = Number.isFinite(rawNx) && Number.isFinite(rawNy);
const hasLatLon = Number.isFinite(latitude) && Number.isFinite(longitude);
if (!hasGrid && !hasLatLon) {
throw new Error("Provide nx/ny or lat/lon.");
}
if ((Number.isFinite(rawNx) && !Number.isFinite(rawNy)) || (!Number.isFinite(rawNx) && Number.isFinite(rawNy))) {
throw new Error("Provide both nx and ny.");
}
if ((Number.isFinite(latitude) && !Number.isFinite(longitude)) || (!Number.isFinite(latitude) && Number.isFinite(longitude))) {
throw new Error("Provide both lat and lon.");
}
if (hasLatLon && (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180)) {
throw new Error("Provide valid lat and lon.");
}
const pageNo = parseInteger(query.pageNo ?? query.page_no, 1);
const numOfRows = parseInteger(query.numOfRows ?? query.num_of_rows, 1000);
const dataType = trimOrNull(query.dataType ?? query.data_type)?.toUpperCase() || "JSON";
const rawBaseDate = trimOrNull(query.baseDate ?? query.base_date);
const rawBaseTime = trimOrNull(query.baseTime ?? query.base_time);
if ((rawBaseDate && !rawBaseTime) || (!rawBaseDate && rawBaseTime)) {
throw new Error("Provide both baseDate and baseTime.");
}
if (pageNo < 1 || numOfRows < 1) {
throw new Error("Provide valid pageNo and numOfRows.");
}
if (!["JSON", "XML"].includes(dataType)) {
throw new Error("Provide dataType as JSON or XML.");
}
const { baseDate, baseTime } = rawBaseDate && rawBaseTime
? {
baseDate: rawBaseDate,
baseTime: rawBaseTime
}
: resolveLatestKmaForecastBase(now);
if (!/^\d{8}$/.test(baseDate) || !/^\d{4}$/.test(baseTime)) {
throw new Error("Provide baseDate as YYYYMMDD and baseTime as HHMM.");
}
const grid = hasGrid ? { nx: rawNx, ny: rawNy } : convertLatLonToKmaGrid(latitude, longitude);
if (!Number.isFinite(grid.nx) || !Number.isFinite(grid.ny)) {
throw new Error(hasGrid ? "Provide valid nx and ny." : "Provide valid lat and lon.");
}
return {
baseDate,
baseTime,
nx: grid.nx,
ny: grid.ny,
pageNo,
numOfRows,
dataType
};
}
function normalizeOpinetAroundQuery(query) {
const x = parseFloatValue(query.x);
const y = parseFloatValue(query.y);
@ -387,6 +539,49 @@ async function proxySeoulSubwayRequest({
};
}
async function proxyKmaWeatherRequest({
baseDate,
baseTime,
nx,
ny,
pageNo = 1,
numOfRows = 1000,
dataType = "JSON",
apiKey,
fetchImpl = global.fetch
}) {
if (!apiKey) {
return {
statusCode: 503,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "upstream_not_configured",
message: "KMA_OPEN_API_KEY is not configured on the proxy server."
})
};
}
const url = new URL(`${DATA_GO_KR_UPSTREAM_BASE_URL}/1360000/VilageFcstInfoService_2.0/getVilageFcst`);
url.searchParams.set("serviceKey", apiKey);
url.searchParams.set("pageNo", String(pageNo));
url.searchParams.set("numOfRows", String(numOfRows));
url.searchParams.set("dataType", dataType);
url.searchParams.set("base_date", baseDate);
url.searchParams.set("base_time", baseTime);
url.searchParams.set("nx", String(nx));
url.searchParams.set("ny", String(ny));
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 proxyOpinetRequest({ path, params, apiKey, fetchImpl = global.fetch }) {
if (!apiKey) {
return {
@ -465,8 +660,7 @@ async function proxyHrfcoWaterLevelRequest({
}
}
function buildServer({ env = process.env, provider = null } = {}) {
function buildServer({ env = process.env, provider = null, now = () => new Date() } = {}) {
const config = buildConfig(env);
const cache = createMemoryCache();
const rateLimit = buildRateLimiter(config);
@ -497,6 +691,7 @@ function buildServer({ env = process.env, provider = null } = {}) {
port: config.port,
upstreams: {
airKoreaConfigured: Boolean(config.airKoreaApiKey),
kmaOpenApiConfigured: Boolean(config.kmaOpenApiKey),
blueRibbonConfigured: Boolean(config.blueRibbonSessionId),
seoulOpenApiConfigured: Boolean(config.seoulOpenApiKey),
hrfcoConfigured: Boolean(config.hrfcoApiKey),
@ -644,6 +839,67 @@ function buildServer({ env = process.env, provider = null } = {}) {
return payload;
});
app.get("/v1/korea-weather/forecast", async (request, reply) => {
let normalized;
try {
normalized = normalizeKmaForecastQuery(request.query || {}, now());
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "korea-weather-forecast",
...normalized
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
const upstream = await proxyKmaWeatherRequest({
...normalized,
apiKey: config.kmaOpenApiKey
});
reply.code(upstream.statusCode);
reply.header("content-type", upstream.contentType);
if (!upstream.contentType.includes("json")) {
return upstream.body;
}
const payload = JSON.parse(upstream.body);
payload.query = { ...normalized };
payload.proxy = {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
};
if (upstream.statusCode >= 200 && upstream.statusCode < 300) {
cache.set(cacheKey, payload, config.cacheTtlMs);
}
return payload;
});
app.get("/v1/han-river/water-level", async (request, reply) => {
let normalized;
@ -1374,9 +1630,11 @@ if (require.main === module) {
module.exports = {
buildConfig,
buildServer,
convertLatLonToKmaGrid,
normalizeBlueRibbonNearbyQuery,
normalizeFineDustQuery,
normalizeHanRiverWaterLevelQuery,
normalizeKmaForecastQuery,
normalizeKoreanStockLookupQuery,
normalizeKoreanStockSearchQuery,
normalizeOpinetAroundQuery,
@ -1386,7 +1644,9 @@ module.exports = {
normalizeSeoulSubwayQuery,
proxyAirKoreaRequest,
proxyHrfcoWaterLevelRequest,
proxyKmaWeatherRequest,
proxyOpinetRequest,
proxySeoulSubwayRequest,
resolveLatestKmaForecastBase,
startServer
};

View file

@ -4,8 +4,9 @@ const assert = require("node:assert/strict");
const {
buildServer,
proxyAirKoreaRequest,
proxySeoulSubwayRequest,
proxyHrfcoWaterLevelRequest
proxyHrfcoWaterLevelRequest,
proxyKmaWeatherRequest,
proxySeoulSubwayRequest
} = require("../src/server");
test("health endpoint stays public and reports auth/upstream status", async (t) => {
@ -29,8 +30,10 @@ test("health endpoint stays public and reports auth/upstream status", async (t)
assert.equal(body.ok, true);
assert.equal(body.auth.tokenRequired, false);
assert.equal(body.upstreams.airKoreaConfigured, false);
assert.equal(body.upstreams.kmaOpenApiConfigured, false);
assert.equal(body.upstreams.krxConfigured, false);
assert.equal(body.upstreams.seoulOpenApiConfigured, false);
assert.equal(body.upstreams.hrfcoConfigured, false);
});
test("health endpoint reports KRX upstream status when configured", async (t) => {
@ -830,6 +833,222 @@ test("proxySeoulSubwayRequest injects API key and preserves index/station params
assert.match(calledUrl, /\/api\/subway\/test-seoul-key\/json\/realtimeStationArrival\/2\/5\/%EA%B0%95%EB%82%A8$/);
});
test("korea weather endpoint caches successful upstream responses for normalized coordinate queries", async (t) => {
const originalFetch = global.fetch;
let fetchCalls = 0;
global.fetch = async (url) => {
fetchCalls += 1;
assert.match(String(url), /getVilageFcst/);
assert.match(String(url), /base_date=20260405/);
assert.match(String(url), /base_time=0500/);
assert.match(String(url), /nx=60/);
assert.match(String(url), /ny=127/);
return new Response(
JSON.stringify({
response: {
header: {
resultCode: "00",
resultMsg: "NORMAL_SERVICE"
},
body: {
dataType: "JSON",
items: {
item: [
{
baseDate: "20260405",
baseTime: "0500",
category: "TMP",
fcstDate: "20260405",
fcstTime: "0600",
fcstValue: "14",
nx: 60,
ny: 127
}
]
}
}
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
};
const app = buildServer({
env: {
KMA_OPEN_API_KEY: "kma-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
},
now: () => new Date("2026-04-05T06:30:00+09:00")
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const first = await app.inject({
method: "GET",
url: "/v1/korea-weather/forecast?lat=37.5665&lon=126.978"
});
const second = await app.inject({
method: "GET",
url: "/v1/korea-weather/forecast?nx=60&ny=127&baseDate=20260405&baseTime=0500"
});
assert.equal(first.statusCode, 200);
assert.equal(second.statusCode, 200);
assert.equal(fetchCalls, 1);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
assert.deepEqual(first.json().query, {
baseDate: "20260405",
baseTime: "0500",
nx: 60,
ny: 127,
pageNo: 1,
numOfRows: 1000,
dataType: "JSON"
});
});
test("korea weather endpoint stays publicly callable without proxy auth", async (t) => {
const originalFetch = global.fetch;
let calledUrl;
global.fetch = async (url) => {
calledUrl = String(url);
return new Response(
JSON.stringify({
response: {
header: {
resultCode: "00",
resultMsg: "NORMAL_SERVICE"
},
body: {
dataType: "JSON",
items: {
item: []
}
}
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
};
const app = buildServer({
env: {
KMA_OPEN_API_KEY: "kma-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/korea-weather/forecast?nx=60&ny=127&baseDate=20260405&baseTime=0500"
});
assert.equal(response.statusCode, 200);
assert.equal(response.json().response.header.resultCode, "00");
assert.ok(calledUrl.startsWith("https://apis.data.go.kr/"));
assert.match(calledUrl, /serviceKey=kma-key/);
assert.match(calledUrl, /base_date=20260405/);
assert.match(calledUrl, /base_time=0500/);
assert.match(calledUrl, /nx=60/);
assert.match(calledUrl, /ny=127/);
});
test("korea weather endpoint rejects out-of-range coordinates before reaching upstream", async (t) => {
const originalFetch = global.fetch;
let fetchCalls = 0;
global.fetch = async () => {
fetchCalls += 1;
throw new Error("fetch should not be called for invalid coordinates");
};
const app = buildServer({
env: {
KMA_OPEN_API_KEY: "kma-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/korea-weather/forecast?lat=91&lon=126.978"
});
assert.equal(response.statusCode, 400);
assert.deepEqual(response.json(), {
error: "bad_request",
message: "Provide valid lat and lon."
});
assert.equal(fetchCalls, 0);
});
test("korea weather endpoint returns 503 when proxy server lacks KMA API key", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/korea-weather/forecast?nx=60&ny=127"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
});
test("proxyKmaWeatherRequest injects API key and preserves caller query params", async () => {
let calledUrl;
const result = await proxyKmaWeatherRequest({
baseDate: "20260405",
baseTime: "0500",
nx: 60,
ny: 127,
pageNo: 2,
numOfRows: 50,
dataType: "JSON",
apiKey: "test-kma-key",
fetchImpl: async (url) => {
calledUrl = String(url);
return new Response('{"ok":true}', {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
}
});
assert.equal(result.statusCode, 200);
assert.ok(calledUrl.startsWith("https://apis.data.go.kr/"));
assert.match(calledUrl, /\/1360000\/VilageFcstInfoService_2\.0\/getVilageFcst\?/);
assert.match(calledUrl, /serviceKey=test-kma-key/);
assert.match(calledUrl, /base_date=20260405/);
assert.match(calledUrl, /base_time=0500/);
assert.match(calledUrl, /nx=60/);
assert.match(calledUrl, /ny=127/);
assert.match(calledUrl, /pageNo=2/);
assert.match(calledUrl, /numOfRows=50/);
assert.match(calledUrl, /dataType=JSON/);
});
test("han river water-level endpoint stays publicly callable without proxy auth", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];

View file

@ -350,6 +350,50 @@ test("seoul subway docs require an explicit proxy until the hosted route is live
assert.doesNotMatch(secretsExample, /KSKILL_PROXY_BASE_URL=https:\/\/k-skill-proxy\.nomadamas\.org/);
});
test("repository docs advertise the korea-weather skill across the documented surfaces", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
const sources = read(path.join("docs", "sources.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "korea-weather.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/korea-weather.md to exist");
assert.match(readme, /\| 한국 날씨 조회 \|/);
assert.match(readme, /\[한국 날씨 조회 가이드\]\(docs\/features\/korea-weather\.md\)/);
assert.match(install, /--skill korea-weather/);
assert.match(roadmap, /한국 날씨 조회 스킬 출시/);
assert.match(sources, /기상청 단기예보 조회서비스: https:\/\/www\.data\.go\.kr\/data\/15084084\/openapi\.do/);
});
test("korea-weather docs route short-term forecast calls through the proxy without requiring a user API key", () => {
const skillPath = path.join(repoRoot, "korea-weather", "SKILL.md");
assert.ok(fs.existsSync(skillPath), "expected korea-weather/SKILL.md to exist");
const skill = read(path.join("korea-weather", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "korea-weather.md"));
const proxyDoc = read(path.join("docs", "features", "k-skill-proxy.md"));
const proxyReadme = read(path.join("packages", "k-skill-proxy", "README.md"));
assert.match(skill, /^name: korea-weather$/m);
assert.match(skill, /^description: .*날씨.*기상청.*프록시.*$/m);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /\/v1\/korea-weather\/forecast/);
assert.match(doc, /기상청.*단기예보|단기예보.*기상청/);
assert.match(doc, /사용자가 .*API key.*직접.*필요(가|는)? 없다|개인 API key 없이/i);
assert.match(doc, /nx|ny|위도|경도/u);
assert.match(doc, /TMP|SKY|PTY|POP/);
assert.match(doc, /KSKILL_PROXY_BASE_URL|k-skill-proxy\.nomadamas\.org/);
assert.doesNotMatch(doc, /KMA_OPEN_API_KEY=.*사용자/);
}
assert.match(proxyDoc, /GET \/v1\/korea-weather\/forecast/);
assert.match(proxyDoc, /KMA_OPEN_API_KEY/);
assert.match(proxyReadme, /GET \/v1\/korea-weather\/forecast/);
assert.match(proxyReadme, /KMA_OPEN_API_KEY/);
});
test("kakaotalk-mac skill documents safe macOS kakaocli usage", () => {
const skillPath = path.join(repoRoot, "kakaotalk-mac", "SKILL.md");