Skill: sw326/kma-weather-korea (version 2.2.0)

--- name: kma-weather description: KMA short-term forecast API - ultra-short-term observation/forecast, short-term forecast version: 2.2.0 author: chumjibot created: 2026-02-10 updated: 2026-02-19 tags: [weather, korea, openapi, data.go.kr] connectors: [~~weather, ~~air, ~~notify] --- # KMA Weather Forecast Skill Korea Meteorological Administration short-term forecast API + AirKorea air quality integration. ## Overview | Key | Value | |-----|-------| | Provider | KMA (Korea Meteorological Administration) | | Service ID | 15084084 | | Auth | `~/.config/data-go-kr/api_key` | | Endpoint | `https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0` | | Playbook | `playbook.md` | ## Scripts ``` scripts/ ├── weather.sh → Weather query (ncst/fcst/short) ├── morning_briefing.sh → Morning briefing (weather + air quality) └── grid_convert.py → Lat/lon → grid coordinate conversion ``` ## Workflow ### Step 1: Identify location & time - "서울 날씨" → Seoul Jongno-gu (nx=60, ny=127) - "내일 날씨" → short-term forecast (short) - "지금 비 와?" → ultra-short-term observation (ncst) - Refer to `playbook.md` for default location ### Step 2: Fetch weather data - Current: `weather.sh ncst [nx] [ny]` - 6-hour: `weather.sh fcst [nx] [ny]` - 3-day: `weather.sh short [nx] [ny]` ### Step 3: Air quality integration (~~air) - Fetch PM data via AirKorea skill - `skills/airkorea-air-quality/scripts/air_quality.sh [station]` ### Step 4: Structured response using output template ## Output Template ```markdown ## 🌤️ [Location] Weather ### Current 🌡️ Temp: X°C (Feels like X°C) 💧 Humidity: X% | 🌬️ Wind: Xm/s 🌧️ Precip: [None/Rain/Snow] ### Today's Forecast ☀️ High X°C / Low X°C 🌧️ Precip probability: X% 🌫️ PM2.5: [Good/Moderate/Bad/Very Bad] (X㎍/㎥) ### 💡 One-liner [Bring umbrella / Wear mask / Great day to go out] ``` ## API Types | API | Command | Description | Update Cycle | |-----|---------|-------------|-------------| | Ultra-short obs | `ncst` | Current observation | Hourly | | Ultra-short fcst | `fcst` | 6hr forecast | Every 30min | | Short-term fcst | `short` | 3-day hourly | 8x/day | ## Key Grid Coordinates | Location | nx | ny | |----------|----|----| | Seoul Jongno | 60 | 127 | | Seoul Gangnam | 61 | 126 | | Busan | 98 | 76 | | Daegu | 89 | 90 | | Incheon | 55 | 124 | | Daejeon | 67 | 100 | | Jeju | 52 | 38 | ## Codes ### Sky (SKY) `1`: Clear ☀️ | `3`: Mostly cloudy ⛅ | `4`: Overcast ☁️ ### Precipitation (PTY) `0`: None | `1`: Rain 🌧️ | `2`: Rain/Snow 🌧️❄️ | `3`: Snow ❄️ | `4`: Shower 🌦️ ## base_time Rules | API | Release time | Queryable | |-----|-------------|-----------| | Ultra-short obs | Every hour | +10min | | Ultra-short fcst | Every 30min | +10min | | Short-term fcst | 02,05,08,11,14,17,20,23h | +10min | ## Connectors | Placeholder | Purpose | Current Tool | |-------------|---------|-------------| | `~~weather` | Weather API | KMA Short-term Forecast | | `~~air` | Air quality | AirKorea | | `~~notify` | Notification | Telegram | ## Intent Router | Intent | Trigger Examples | Strategy | |--------|-----------------|----------| | Quick Check | "오늘 날씨 어때?", "지금 비 와?", "기온 몇 도?" | `ncst` (현재 관측) + `fcst` (3시간 예보) → 간단 요약 | | Daily Forecast | "내일 비 와?", "주말 날씨", "모레 눈 올까?" | `short` (단기예보 3일) → 시간대별 상세 | | Morning Briefing | "아침 날씨 브리핑" | `morning_briefing.sh` → 날씨+대기질 통합 | **Routing logic:** 현재/지금 → Quick Check, 내일/모레/주말 → Daily Forecast, 브리핑 → Morning Briefing ## Cross-Skill Integration | Trigger | Target Skill | Integration | |---------|-------------|-------------| | 날씨 응답 시 항상 | `airkorea-air-quality` | PM2.5 수치를 날씨 응답에 포함 (Output Template 참조) | | 미세먼지 나쁨 이상 | `airkorea-air-quality` | "마스크 챙기세요" 팁 추가 | | 비/눈 예보 시 | — | "우산 챙기세요" 자동 팁 | ## Notes 1. Uses grid coordinate system (not lat/lon) → use `grid_convert.py` 2. Query after release time + 10 minutes 3. Short-term forecast returns large data → set sufficient `numOfRows` --- *Cowork architecture v2.2 — 🦞 chumjibot (2026-02-19)* ## 🔧 Setup (공공데이터 포털 API) 1. [data.go.kr](https://www.data.go.kr) 회원가입 2. 로그인 → 마이페이지 → **일반 인증키(Decoding)** 복사 3. API 키 저장: ```bash mkdir -p ~/.config/data-go-kr echo "YOUR_API_KEY" > ~/.config/data-go-kr/api_key ``` 4. 아래 서비스 **활용신청** 후 사용 (자동승인) - [기상청 단기예보](https://www.data.go.kr/data/15084084/openapi.do) (15084084)

path: playbook.md
content: # 날씨 플레이북 — 개인 설정

## 기본 지역
- 서울 종로구 (nx=60, ny=127) — 메인
- 측정소: 종로구 (대기질)

## 출력 선호
- 이모지 포함 (☀️🌧️❄️ 등)
- 체감온도 포함
- 미세먼지 항상 포함 (~~air 연동)
- 한줄 조언 (우산/마스크/외출 추천)

## 아침 브리핑 포맷
- 시간: 매일 07:30
- 채널: ~~notify (Notification)
- 포함: 현재기온 + 최고/최저 + 강수확률 + 미세먼지
- 크론 스크립트: morning_briefing.sh

## 알림 기준
- 강수확률 60% 이상 → "우산 챙기세요!" 🌂
- PM2.5 나쁨(36+) → "마스크 권장!" 😷
- 기온 -5°C 이하 → "한파 주의!" 🥶
- 기온 33°C 이상 → "폭염 주의!" 🥵


path: scripts/grid_convert.py
content: #!/usr/bin/env python3
"""
기상청 격자좌표 변환 (위경도 ↔ 격자)
Lambert Conformal Conic (LCC) DFS 좌표계
"""

import math
import sys
import json

# 격자 변환 상수
RE = 6371.00877     # 지구 반경(km)
GRID = 5.0          # 격자 간격(km)
SLAT1 = 30.0        # 투영 위도1(degree)
SLAT2 = 60.0        # 투영 위도2(degree)
OLON = 126.0        # 기준점 경도(degree)
OLAT = 38.0         # 기준점 위도(degree)
XO = 43             # 기준점 X좌표(GRID)
YO = 136            # 기준점 Y좌표(GRID)

def latlon_to_grid(lat, lon):
    """위경도 → 기상청 격자좌표 변환"""
    DEGRAD = math.pi / 180.0
    re = RE / GRID
    slat1 = SLAT1 * DEGRAD
    slat2 = SLAT2 * DEGRAD
    olon = OLON * DEGRAD
    olat = OLAT * DEGRAD
    
    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)
    sf = math.tan(math.pi * 0.25 + slat1 * 0.5)
    sf = math.pow(sf, sn) * math.cos(slat1) / sn
    ro = math.tan(math.pi * 0.25 + olat * 0.5)
    ro = re * sf / math.pow(ro, sn)
    
    ra = math.tan(math.pi * 0.25 + lat * DEGRAD * 0.5)
    ra = re * sf / math.pow(ra, sn)
    theta = lon * DEGRAD - olon
    if theta > math.pi:
        theta -= 2.0 * math.pi
    if theta < -math.pi:
        theta += 2.0 * math.pi
    theta *= sn
    
    x = int(ra * math.sin(theta) + XO + 0.5)
    y = int(ro - ra * math.cos(theta) + YO + 0.5)
    
    return x, y

def grid_to_latlon(x, y):
    """기상청 격자좌표 → 위경도 변환"""
    DEGRAD = math.pi / 180.0
    RADDEG = 180.0 / math.pi
    
    re = RE / GRID
    slat1 = SLAT1 * DEGRAD
    slat2 = SLAT2 * DEGRAD
    olon = OLON * DEGRAD
    olat = OLAT * DEGRAD
    
    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)
    sf = math.tan(math.pi * 0.25 + slat1 * 0.5)
    sf = math.pow(sf, sn) * math.cos(slat1) / sn
    ro = math.tan(math.pi * 0.25 + olat * 0.5)
    ro = re * sf / math.pow(ro, sn)
    
    xn = x - XO
    yn = ro - y + YO
    ra = math.sqrt(xn * xn + yn * yn)
    if sn < 0.0:
        ra = -ra
    alat = math.pow((re * sf / ra), (1.0 / sn))
    alat = 2.0 * math.atan(alat) - math.pi * 0.5
    
    theta = 0.0
    if abs(xn) <= 0.0:
        theta = 0.0
    else:
        if abs(yn) <= 0.0:
            theta = math.pi * 0.5
            if xn < 0.0:
                theta = -theta
        else:
            theta = math.atan2(xn, yn)
    
    alon = theta / sn + olon
    lat = alat * RADDEG
    lon = alon * RADDEG
    
    return lat, lon

def main():
    if len(sys.argv) < 3:
        print("Usage: grid_convert.py <lat> <lon>  # 위경도 → 격자", file=sys.stderr)
        print("       grid_convert.py -r <nx> <ny>  # 격자 → 위경도", file=sys.stderr)
        sys.exit(1)
    
    if sys.argv[1] == '-r':
        # 역변환
        nx = int(sys.argv[2])
        ny = int(sys.argv[3])
        lat, lon = grid_to_latlon(nx, ny)
        result = {
            "nx": nx,
            "ny": ny,
            "lat": round(lat, 6),
            "lon": round(lon, 6)
        }
    else:
        # 정변환
        lat = float(sys.argv[1])
        lon = float(sys.argv[2])
        nx, ny = latlon_to_grid(lat, lon)
        result = {
            "lat": lat,
            "lon": lon,
            "nx": nx,
            "ny": ny
        }
    
    print(json.dumps(result, ensure_ascii=False, indent=2))

if __name__ == '__main__':
    main()


path: scripts/morning_briefing.sh
content: #!/bin/bash
# 아침 브리핑용 날씨+미세먼지 종합 스크립트
# Usage: morning_briefing.sh [nx] [ny] [측정소명]

NX=${1:-60}
NY=${2:-127}
STATION=${3:-종로구}

python3 - "$NX" "$NY" "$STATION" << 'PYEOF'
import urllib.request, urllib.parse, json, sys
from datetime import datetime, timedelta

NX, NY, STATION = sys.argv[1], sys.argv[2], sys.argv[3]
API_KEY = open("/home/scott/.config/data-go-kr/api_key").read().strip()

now = datetime.now()
if now.minute < 10:
    base = now - timedelta(hours=1)
else:
    base = now
base_date = base.strftime("%Y%m%d")
base_time = base.strftime("%H") + "00"

# 1. 초단기실황
weather = {}
try:
    url = "https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getUltraSrtNcst"
    p = urllib.parse.urlencode({"serviceKey": API_KEY, "pageNo": "1", "numOfRows": "10",
        "dataType": "JSON", "base_date": base_date, "base_time": base_time, "nx": NX, "ny": NY})
    with urllib.request.urlopen(f"{url}?{p}", timeout=10) as r:
        data = json.loads(r.read().decode())
    for item in data["response"]["body"]["items"]["item"]:
        weather[item["category"]] = item["obsrValue"]
except Exception as e:
    weather["error"] = str(e)

# 2. 단기예보 (최고/최저, 강수확률)
fcst_hours = [2, 5, 8, 11, 14, 17, 20, 23]
fcst_base = None
for h in reversed(fcst_hours):
    if now.hour >= h:
        fcst_base = h; break
fcst_date = now.strftime("%Y%m%d")
if fcst_base is None:
    fcst_base = 23; fcst_date = (now - timedelta(days=1)).strftime("%Y%m%d")

tmn, tmx, pop_max, sky_vals, pty_vals = None, None, 0, [], []
try:
    url = "https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getVilageFcst"
    p = urllib.parse.urlencode({"serviceKey": API_KEY, "pageNo": "1", "numOfRows": "300",
        "dataType": "JSON", "base_date": fcst_date, "base_time": f"{fcst_base:02d}00", "nx": NX, "ny": NY})
    with urllib.request.urlopen(f"{url}?{p}", timeout=15) as r:
        data = json.loads(r.read().decode())
    today = now.strftime("%Y%m%d")
    for item in data["response"]["body"]["items"]["item"]:
        if item["fcstDate"] != today: continue
        cat, val = item["category"], item["fcstValue"]
        if cat == "TMN": tmn = val
        elif cat == "TMX": tmx = val
        elif cat == "POP": pop_max = max(pop_max, int(val))
        elif cat == "SKY": sky_vals.append(int(val))
        elif cat == "PTY": pty_vals.append(int(val))
except Exception as e:
    pass

# 3. 에어코리아
air = {}
try:
    url = "https://apis.data.go.kr/B552584/ArpltnInforInqireSvc/getMsrstnAcctoRltmMesureDnsty"
    p = urllib.parse.urlencode({"serviceKey": API_KEY, "returnType": "json", "numOfRows": "1",
        "pageNo": "1", "stationName": STATION, "dataTerm": "DAILY", "ver": "1.0"})
    with urllib.request.urlopen(f"{url}?{p}", timeout=10) as r:
        data = json.loads(r.read().decode())
    item = data["response"]["body"]["items"][0]
    air = {k: item.get(k, "-") for k in ["pm10Value","pm25Value","pm10Grade","pm25Grade","o3Value","khaiGrade"]}
except Exception as e:
    air["error"] = str(e)

# 종합
sky_map = {1: "맑음 ☀️", 3: "구름많음 ⛅", 4: "흐림 ☁️"}
pty_map = {0: "", 1: "비 🌧️", 2: "비/눈 🌧️❄️", 3: "눈 ❄️", 4: "소나기 🌦️"}
grade_map = {"1": "좋음😊", "2": "보통🙂", "3": "나쁨😷", "4": "매우나쁨🤢"}

main_sky = max(set(sky_vals), key=sky_vals.count) if sky_vals else None
main_pty = max(pty_vals) if pty_vals else 0

result = {
    "current_temp": weather.get("T1H", "?"),
    "humidity": weather.get("REH", "?"),
    "wind_speed": weather.get("WSD", "?"),
    "precip_type": int(weather.get("PTY", "0")),
    "tmn": tmn, "tmx": tmx,
    "pop_max": pop_max,
    "sky": sky_map.get(main_sky, "?"),
    "pty": pty_map.get(main_pty, ""),
    "pm10": air.get("pm10Value", "-"),
    "pm25": air.get("pm25Value", "-"),
    "pm10_grade": grade_map.get(air.get("pm10Grade"), "?"),
    "pm25_grade": grade_map.get(air.get("pm25Grade"), "?"),
    "umbrella": pop_max >= 40 or main_pty > 0,
}
print(json.dumps(result, ensure_ascii=False, indent=2))
PYEOF


path: scripts/weather.sh
content: #!/bin/bash
# 기상청 단기예보 API 조회 스크립트

set -euo pipefail

# API 키 읽기
API_KEY=$(cat ~/.config/data-go-kr/api_key 2>/dev/null || echo "")
if [ -z "$API_KEY" ]; then
    echo '{"error": "API key not found at ~/.config/data-go-kr/api_key"}' >&2
    exit 1
fi

# 기본값
API_TYPE="${1:-ncst}"
NX="${2:-60}"  # 서울 종로구
NY="${3:-127}"

# 현재 날짜/시각
NOW=$(date +"%Y%m%d %H%M")
BASE_DATE=$(echo $NOW | cut -d' ' -f1)
CURRENT_TIME=$(echo $NOW | cut -d' ' -f2)
CURRENT_HOUR=$(echo $CURRENT_TIME | cut -c1-2)
CURRENT_MIN=$(echo $CURRENT_TIME | cut -c3-4)

# base_time 계산 함수
get_base_time_ncst() {
    # 초단기실황: 매시 정각, 10분 후 조회 가능
    local hour=$1
    local min=$2
    
    if [ $min -lt 10 ]; then
        # 아직 발표 전 → 이전 시간
        hour=$((10#$hour - 1))
        if [ $hour -lt 0 ]; then
            hour=23
            BASE_DATE=$(date -d "$BASE_DATE -1 day" +%Y%m%d)
        fi
    fi
    
    printf "%02d00" $hour
}

get_base_time_fcst() {
    # 초단기예보: 매 30분, 10분 후 조회 가능
    local hour=$1
    local min=$2
    
    if [ $min -lt 40 ]; then
        # 30분 발표분 조회 가능
        if [ $min -ge 10 ]; then
            printf "%02d30" $hour
        else
            # 이전 시간 30분
            hour=$((10#$hour - 1))
            if [ $hour -lt 0 ]; then
                hour=23
                BASE_DATE=$(date -d "$BASE_DATE -1 day" +%Y%m%d)
            fi
            printf "%02d30" $hour
        fi
    else
        # 정각 발표분 조회 가능
        printf "%02d00" $((10#$hour + 1))
    fi
}

get_base_time_short() {
    # 단기예보: 0200, 0500, 0800, 1100, 1400, 1700, 2000, 2300
    local hour=$1
    local times=(02 05 08 11 14 17 20 23)
    
    for time in "${times[@]}"; do
        if [ $((10#$hour)) -ge $((10#$time)) ]; then
            BASE_TIME="${time}00"
        fi
    done
    
    echo $BASE_TIME
}

# API 엔드포인트 선택
case "$API_TYPE" in
    ncst|실황)
        ENDPOINT="getUltraSrtNcst"
        BASE_TIME=$(get_base_time_ncst $CURRENT_HOUR $CURRENT_MIN)
        ;;
    fcst|초단기|예보)
        ENDPOINT="getUltraSrtFcst"
        BASE_TIME=$(get_base_time_fcst $CURRENT_HOUR $CURRENT_MIN)
        ;;
    short|단기)
        ENDPOINT="getVilageFcst"
        BASE_TIME=$(get_base_time_short $CURRENT_HOUR)
        ;;
    version|버전)
        ENDPOINT="getFcstVersion"
        BASE_TIME="0000"
        ;;
    *)
        echo '{"error": "Invalid API type. Use: ncst, fcst, short, version"}' >&2
        exit 1
        ;;
esac

# API 호출
URL="https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/${ENDPOINT}"

PARAMS="serviceKey=${API_KEY}"
PARAMS="${PARAMS}&pageNo=1"
PARAMS="${PARAMS}&numOfRows=1000"
PARAMS="${PARAMS}&dataType=JSON"
PARAMS="${PARAMS}&base_date=${BASE_DATE}"
PARAMS="${PARAMS}&base_time=${BASE_TIME}"
PARAMS="${PARAMS}&nx=${NX}"
PARAMS="${PARAMS}&ny=${NY}"

# 디버그 정보 (stderr로 출력)
echo "API: $ENDPOINT | Date: $BASE_DATE | Time: $BASE_TIME | Grid: ($NX,$NY)" >&2

# 요청 실행
RESPONSE=$(curl -s -G "$URL" --data-urlencode "serviceKey=$API_KEY" \
    --data "pageNo=1" \
    --data "numOfRows=1000" \
    --data "dataType=JSON" \
    --data "base_date=$BASE_DATE" \
    --data "base_time=$BASE_TIME" \
    --data "nx=$NX" \
    --data "ny=$NY")

# 응답 출력
echo "$RESPONSE" | jq '.'


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)