mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
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:
commit
7a2ea259b0
14 changed files with 740 additions and 15 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 로 검증합니다.
|
||||
|
|
|
|||
70
docs/features/korea-weather.md
Normal file
70
docs/features/korea-weather.md
Normal 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)를 본다.
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
- 토스증권 조회 스킬 출시
|
||||
- 로또 당첨번호
|
||||
- 서울 지하철 도착 정보
|
||||
- 한국 날씨 조회 스킬 출시
|
||||
- 사용자 위치 미세먼지 조회 스킬 출시
|
||||
- 한강 수위 정보 조회 스킬 출시
|
||||
- 한국 법령 검색 스킬 출시
|
||||
|
|
@ -112,10 +113,10 @@
|
|||
- 장점: 결제/포인트/배송/가격비교를 묶는 쇼핑형 허브 후보가 된다
|
||||
- 이유: 스마트스토어와 별도의 큰 축으로 키울 수 있다
|
||||
|
||||
#### 한국 기상청 날씨/특보
|
||||
#### 한국 기상청 특보/중기예보 확장
|
||||
|
||||
- 장점: 국내 날씨, 특보, 단기 예보를 한국형 생활 정보로 직접 연결하기 좋다
|
||||
- 이유: 미세먼지 스킬과 함께 묶었을 때 생활 밀착도가 더 올라간다
|
||||
- 장점: 이미 선출시한 한국 날씨 조회 스킬에 특보/중기예보를 붙여 생활 정보 깊이를 늘릴 수 있다
|
||||
- 이유: 단기예보 다음 단계로 자연스럽게 확장 가능하다
|
||||
|
||||
### 기존 탐색 후보
|
||||
|
||||
|
|
|
|||
|
|
@ -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)를 본다.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
101
korea-weather/SKILL.md
Normal 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` 를 참고한다.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue