Compare commits

...

5 commits

Author SHA1 Message Date
Jeffrey (Dongkyu) Kim
3960330f16 Merge dev into feature/#57: resolve conflicts with korean-stock and bunjang additions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 04:34:12 +09:00
Jeffrey (Dongkyu) Kim
aac4d15ba0 Preserve feature/#57 while reconciling dev proxy changes
Merged origin/dev into feature/#57 and resolved the proxy/doc conflicts by keeping the korea-weather work while integrating the newer han-river and setup documentation updates from dev. The resulting branch keeps both public proxy surfaces, aligned docs, and combined regression coverage so the PR can be reviewed again without dropping either lane of work.

Constraint: PR #69 had to remain a merge-conflict-resolution update on feature/#57 without merging into dev directly
Constraint: Proxy/server docs and tests needed to reflect both korea-weather and han-river routes after the merge
Rejected: Favor HEAD-only conflict resolution | would have dropped dev's han-river proxy changes
Rejected: Favor dev-only conflict resolution | would have dropped feature/#57 korea-weather behavior and coverage
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep docs/setup/security guidance in sync with every public proxy route added to k-skill-proxy
Tested: npm run ci
Tested: npx tsc --noEmit --pretty false --project /Users/jeffrey/Projects/k-skill/tsconfig.json
Tested: Local HTTP smoke test for GET /v1/korea-weather/forecast with mocked upstream fetch
Not-tested: Live upstream network calls against deployed proxy infrastructure
2026-04-06 01:16:20 +09:00
Jeffrey (Dongkyu) Kim
ac22a76d94 Protect proxied public-data API keys with HTTPS transport
The new korea-weather route already hid the KMA service key behind the proxy, but
it was still forwarding that key over plaintext HTTP. Switching the shared
data.go.kr proxy base URL to HTTPS keeps the existing request contract intact
while closing the transport exposure. Regression coverage now locks the weather
route and direct KMA proxy helper to HTTPS so the downgrade does not regress.

Constraint: The follow-up had to stay minimal on the existing PR branch
Rejected: Add a KMA-only base URL constant | unnecessary divergence from the shared data.go.kr transport setting
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep proxied upstreams on HTTPS whenever the provider supports it, especially when operator-managed keys are injected server-side
Tested: node --test packages/k-skill-proxy/test/server.test.js; local buildServer().inject smoke with mocked fetch; npx tsc --noEmit --pretty false --project /Users/jeffrey/Projects/k-skill/tsconfig.json; npm run ci
Not-tested: Live network call to KMA upstream with a real service key
2026-04-06 00:29:17 +09:00
Jeffrey (Dongkyu) Kim
78e349d768 Reject invalid weather coordinates before proxying
The korea-weather forecast route already normalized valid lat/lon requests, but
out-of-range inputs could still project to NaN grid values and consume upstream
quota. This change rejects invalid latitude/longitude pairs at the boundary and
locks the behavior with a regression that proves invalid coordinates never
reach fetch.

Constraint: Public proxy endpoints must reject malformed caller input before upstream calls
Rejected: Allow upstream KMA failures to handle invalid coordinates | wastes quota and returns opaque errors
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep coordinate validation local to query normalization so cache keys and upstream URLs only see finite grid values
Tested: node --test packages/k-skill-proxy/test/server.test.js; local app.inject smoke for /v1/korea-weather/forecast with mocked fetch; npx tsc --noEmit --pretty false --project /Users/jeffrey/Projects/k-skill/tsconfig.json; npm run ci
Not-tested: Live KMA upstream behavior for invalid coordinates (intentionally blocked before proxying)
2026-04-05 21:54:26 +09:00
Jeffrey (Dongkyu) Kim
c844bbd89e Hide KMA forecast keys behind the public proxy
Add a Korea weather skill plus a new k-skill-proxy route for the
KMA short-term forecast API. The route accepts grid coordinates or
lat/lon, defaults to the latest safe base time, and keeps the user
workflow keyless while the proxy owns the upstream credential.

Constraint: KMA short-term forecast requires a service key and 5km grid parameters
Constraint: Must keep the client flow dependency-free and free of user API issuance
Rejected: Direct client-side KMA calls | would force end users to issue and store their own keys
Rejected: Add a third-party geocoder dependency | unnecessary for v1 when lat/lon-to-grid conversion is enough
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep future KMA integrations on explicit allowlisted proxy routes and avoid documenting upstream keys for clients
Tested: npm run ci; local HTTP smoke test for /v1/korea-weather/forecast with mocked upstream fetch; npx tsc --noEmit --pretty false --project /Users/jeffrey/Projects/k-skill/tsconfig.json
Not-tested: Live KMA upstream call with a production service key
2026-04-05 20:23:40 +09:00
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) |
@ -73,6 +74,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

@ -55,6 +55,7 @@ npx --yes skills add <owner/repo> \
--skill real-estate-search \
--skill korean-stock-search \
--skill joseon-sillok-search \
--skill korea-weather \
--skill cheap-gas-nearby \
--skill fine-dust-location \
--skill han-river-water-level \
@ -82,6 +83,7 @@ npx --yes skills add <owner/repo> \
--skill cheap-gas-nearby \
--skill joseon-sillok-search \
--skill seoul-subway-arrival \
--skill korea-weather \
--skill fine-dust-location
```
@ -271,6 +273,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 @@
- 토스증권 조회 스킬 출시
- 로또 당첨번호
- 서울 지하철 도착 정보
- 한국 날씨 조회 스킬 출시
- 사용자 위치 미세먼지 조회 스킬 출시
- 한강 수위 정보 조회 스킬 출시
- 한국 법령 검색 스킬 출시
@ -111,10 +112,10 @@
- 장점: 결제/포인트/배송/가격비교를 묶는 쇼핑형 허브 후보가 된다
- 이유: 스마트스토어와 별도의 큰 축으로 키울 수 있다
#### 한국 기상청 날씨/특보
#### 한국 기상청 특보/중기예보 확장
- 장점: 국내 날씨, 특보, 단기 예보를 한국형 생활 정보로 직접 연결하기 좋
- 이유: 미세먼지 스킬과 함께 묶었을 때 생활 밀착도가 더 올라간
- 장점: 이미 선출시한 한국 날씨 조회 스킬에 특보/중기예보를 붙여 생활 정보 깊이를 늘릴 수 있
- 이유: 단기예보 다음 단계로 자연스럽게 확장 가능하
### 기존 탐색 후보

View file

@ -29,7 +29,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
@ -65,6 +65,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` 는 프록시 운영자 문맥에서만 서버에 넣는다. 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` 는 프록시 운영자 문맥에서만 서버에 넣는다. 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`, self-host 프록시 운영용 서울 지하철 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다. 미세먼지, 한강 수위, 주유소 가격, 부동산 실거래가, 한국 주식 정보 조회는 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다.
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, self-host 프록시 운영용 서울 지하철/한국 날씨/미세먼지/한강홍수통제소 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다. 미세먼지, 한강 수위, 주유소 가격, 부동산 실거래가, 한국 주식 정보 조회는 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다.
## Credential resolution order
@ -33,7 +33,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` 로 설치 상태를 확인한다.
@ -70,6 +70,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 사용) |
@ -78,6 +79,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

@ -76,6 +76,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

@ -76,7 +76,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으로 안내한다.
@ -101,6 +101,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");