Add local air-quality lookup so k-skill covers location-based dust checks

Issue #17 approved an Air Korea two-API flow with a region fallback, so this change adds a new fine-dust skill, wires it into the repo docs/setup surfaces, and includes a runnable helper plus fixtures/tests for repeatable verification.

Constraint: Must use the approved official Air Korea APIs and secure secret registration flow
Rejected: Use an unofficial air-quality API | issue follow-up explicitly approved the Air Korea two-API flow
Rejected: Require coordinates only | issue discussion required a practical fallback when precise location is unavailable
Confidence: high
Scope-risk: moderate
Directive: Keep the helper and docs aligned with Air Korea endpoint names and fallback order if the official API contract changes
Tested: npm run ci; python3 scripts/fine_dust.py report --station-file scripts/fixtures/fine-dust-stations.json --measurement-file scripts/fixtures/fine-dust-measurements.json --lat 37.5665 --lon 126.9780; python3 scripts/fine_dust.py report --station-file scripts/fixtures/fine-dust-stations.json --measurement-file scripts/fixtures/fine-dust-measurements.json --region-hint '서울 강남구'; python3 scripts/fine_dust.py --help; missing-secret error path without AIR_KOREA_OPEN_API_KEY
Not-tested: Live Air Korea API calls with a real AIR_KOREA_OPEN_API_KEY
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-03-27 22:06:19 +09:00
commit 8c86d54b1f
15 changed files with 828 additions and 5 deletions

View file

@ -20,6 +20,7 @@ Claude code, codex, opencode 등 각종 코딩 에이전트 지원합니다.
| KTX 예매 | Dynapath anti-bot 대응 helper 로 KTX/Korail 열차 조회, 예약, 예약 확인, 취소 | 필요 | [KTX 예매 가이드](docs/features/ktx-booking.md) |
| 카카오톡 Mac CLI | macOS에서 kakaocli로 대화 조회, 검색, 테스트 전송, 확인 후 실제 전송 | 불필요 | [카카오톡 Mac CLI 가이드](docs/features/kakaotalk-mac.md) |
| 서울 지하철 도착정보 조회 | 역 기준 실시간 도착 예정 열차 확인 | 필요 | [서울 지하철 도착정보 가이드](docs/features/seoul-subway-arrival.md) |
| 사용자 위치 미세먼지 조회 | 에어코리아 공식 API로 현재 위치 또는 지역 fallback 기준 PM10/PM2.5 확인 | 필요 | [사용자 위치 미세먼지 조회 가이드](docs/features/fine-dust-location.md) |
| KBO 경기 결과 조회 | 날짜별 경기 일정, 결과, 팀별 필터링 | 불필요 | [KBO 결과 가이드](docs/features/kbo-results.md) |
| 로또 당첨 확인 | 최신 회차, 특정 회차, 번호 대조 | 불필요 | [로또 결과 가이드](docs/features/lotto-results.md) |
| HWP 문서 처리 | `.hwp` → JSON/Markdown/HTML 변환, 이미지 추출, 배치 처리, Windows 직접 제어 선택 | 불필요 | [HWP 문서 처리 가이드](docs/features/hwp.md) |
@ -53,6 +54,7 @@ Claude code, codex, opencode 등 각종 코딩 에이전트 지원합니다.
- [KTX 예매](docs/features/ktx-booking.md)
- [카카오톡 Mac CLI](docs/features/kakaotalk-mac.md)
- [서울 지하철 도착정보 조회](docs/features/seoul-subway-arrival.md)
- [사용자 위치 미세먼지 조회](docs/features/fine-dust-location.md)
- [KBO 경기 결과 조회](docs/features/kbo-results.md)
- [로또 당첨 확인](docs/features/lotto-results.md)
- [HWP 문서 처리](docs/features/hwp.md)

View file

@ -0,0 +1,95 @@
# 사용자 위치 미세먼지 조회 가이드
## 이 기능으로 할 수 있는 일
- 사용자 위치 위도/경도로 가까운 측정소 찾기
- 위치 권한이 없을 때 지역명/행정구역 fallback으로 측정소 찾기
- PM10, PM2.5, 등급, 조회 시각 요약
## 먼저 필요한 것
- [공통 설정 가이드](../setup.md) 완료
- [보안/시크릿 정책](../security-and-secrets.md) 확인
- 에어코리아 OpenAPI key
## 필요한 시크릿
- `AIR_KOREA_OPEN_API_KEY`
## 입력값
- 우선: 현재 위치 위도/경도
- fallback: 지역명/행정구역 힌트 또는 측정소명
## 기본 흐름
1. 좌표가 있으면 측정소정보 API `getNearbyMsrstnList` 로 가까운 측정소를 찾습니다.
2. 좌표를 못 받거나 nearby 결과가 비면 측정소정보 API `getMsrstnList` 로 지역명/행정구역 fallback을 사용합니다.
3. 선택된 측정소 이름으로 대기오염정보 API `getMsrstnAcctoRltmMesureDnsty` 를 호출합니다.
4. PM10, PM2.5, 등급, 조회 시점/조회 시각을 함께 요약합니다.
## 예시
좌표 기반 1차 조회:
```bash
SOPS_AGE_KEY_FILE="$HOME/.config/k-skill/age/keys.txt" \
sops exec-env "$HOME/.config/k-skill/secrets.env" \
'curl -sG "http://apis.data.go.kr/B552584/MsrstnInfoInqireSvc/getNearbyMsrstnList" \
--data-urlencode "serviceKey=${AIR_KOREA_OPEN_API_KEY}" \
--data-urlencode "returnType=json" \
--data-urlencode "numOfRows=10" \
--data-urlencode "pageNo=1" \
--data-urlencode "dmX=37.5665" \
--data-urlencode "dmY=126.9780"'
```
지역 fallback:
```bash
SOPS_AGE_KEY_FILE="$HOME/.config/k-skill/age/keys.txt" \
sops exec-env "$HOME/.config/k-skill/secrets.env" \
'curl -sG "http://apis.data.go.kr/B552584/MsrstnInfoInqireSvc/getMsrstnList" \
--data-urlencode "serviceKey=${AIR_KOREA_OPEN_API_KEY}" \
--data-urlencode "returnType=json" \
--data-urlencode "numOfRows=50" \
--data-urlencode "pageNo=1" \
--data-urlencode "addr=서울 강남구"'
```
실시간 측정값:
```bash
SOPS_AGE_KEY_FILE="$HOME/.config/k-skill/age/keys.txt" \
sops exec-env "$HOME/.config/k-skill/secrets.env" \
'curl -sG "http://apis.data.go.kr/B552584/ArpltnInforInqireSvc/getMsrstnAcctoRltmMesureDnsty" \
--data-urlencode "serviceKey=${AIR_KOREA_OPEN_API_KEY}" \
--data-urlencode "returnType=json" \
--data-urlencode "numOfRows=100" \
--data-urlencode "pageNo=1" \
--data-urlencode "stationName=중구" \
--data-urlencode "dataTerm=DAILY" \
--data-urlencode "ver=1.4"'
```
helper script 반복 검증:
```bash
python3 scripts/fine_dust.py report \
--station-file scripts/fixtures/fine-dust-stations.json \
--measurement-file scripts/fixtures/fine-dust-measurements.json \
--lat 37.5665 \
--lon 126.9780
```
## fallback / 대체 흐름
- 위치 권한이 없으면 지역명/행정구역을 먼저 받습니다
- 지역명도 없으면 측정소명을 직접 받습니다
- `getNearbyMsrstnList` 결과가 비면 `getMsrstnList` 로 재시도합니다
## 주의할 점
- 실시간 수치라 조회 시각을 같이 적어야 합니다
- PM10/PM2.5 값이 `-` 이거나 비정상이면 등급도 함께 재확인합니다
- 위치 기반이라고 해도 실제 기준은 “가까운 측정소” 이므로 주소 중심점과 오차가 있을 수 있습니다

View file

@ -48,6 +48,7 @@ npx --yes skills add <owner/repo> \
--skill kbo-results \
--skill lotto-results \
--skill kakaotalk-mac \
--skill fine-dust-location \
--skill blue-ribbon-nearby \
--skill zipcode-search \
--skill delivery-tracking
@ -60,7 +61,8 @@ npx --yes skills add <owner/repo> \
--skill k-skill-setup \
--skill srt-booking \
--skill ktx-booking \
--skill seoul-subway-arrival
--skill seoul-subway-arrival \
--skill fine-dust-location
```
로컬 저장소에서 바로 전체 설치 테스트:
@ -132,6 +134,7 @@ python3 -m pip install SRTrain korail2
- `srt-booking`
- `ktx-booking`
- `seoul-subway-arrival`
- `fine-dust-location`
관련 문서:

View file

@ -9,6 +9,7 @@
- KBO 경기 결과
- 로또 당첨번호
- 서울 지하철 도착 정보
- 사용자 위치 미세먼지 조회 스킬 출시
- 우편번호 검색
- 근처 블루리본 맛집 스킬 출시
- 택배 배송조회 스킬 출시 (CJ대한통운 / 우체국)
@ -62,7 +63,7 @@
- 장점: 기본 배송조회는 `delivery-tracking` 으로 선출시했고, 다음 단계는 예약/반품/추가 택배사 확장으로 자연스럽게 이어진다
- 이유: 한국 생활에서 반복 빈도가 높은 작업이라 조회 다음 액션까지 묶을 가치가 크다
#### 미세먼지/황사/대기질 알림
#### 미세먼지/황사 예보·알림 확장
- 장점: 오늘/내일/모레 대기정보와 예보, 하루 4회 수준의 예보 갱신 같은 한국형 수요에 잘 맞는다
- 이유: 한국 로컬 생활 스킬로 차별화가 쉽다

View file

@ -40,6 +40,7 @@ KSKILL_SRT_PASSWORD=replace-me
KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
SEOUL_OPEN_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
```
실행은 항상 다음 패턴으로 한다.
@ -93,6 +94,7 @@ sops exec-env "$HOME/.config/k-skill/secrets.env" '<command>'
- `KSKILL_KTX_ID`
- `KSKILL_KTX_PASSWORD`
- `SEOUL_OPEN_API_KEY`
- `AIR_KOREA_OPEN_API_KEY`
## Why sops plus age

View file

@ -2,7 +2,7 @@
`k-skill` 전체 스킬을 설치한 뒤, `k-skill-setup` 스킬이 실제로 수행해야 하는 공통 설정 절차를 이 문서에 정리한다.
SRT 예매, KTX 예매, 서울 지하철 도착정보 조회처럼 인증 정보가 필요한 기능은 설치 직후 이 절차를 진행하면 된다.
SRT 예매, KTX 예매, 서울 지하철 도착정보 조회, 사용자 위치 미세먼지 조회처럼 인증 정보가 필요한 기능은 설치 직후 이 절차를 진행하면 된다.
## 이 설정으로 해결하는 것
@ -73,6 +73,7 @@ KSKILL_SRT_PASSWORD=replace-me
KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
SEOUL_OPEN_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
EOF
```
@ -107,7 +108,7 @@ sops 로 ~/.config/k-skill/secrets.env 로 암호화해 주세요.
```bash
SOPS_AGE_KEY_FILE="$HOME/.config/k-skill/age/keys.txt" \
sops exec-env "$HOME/.config/k-skill/secrets.env" \
'test -n "$KSKILL_SRT_ID" || test -n "$KSKILL_KTX_ID" || test -n "$SEOUL_OPEN_API_KEY"'
'test -n "$KSKILL_SRT_ID" || test -n "$KSKILL_KTX_ID" || test -n "$SEOUL_OPEN_API_KEY" || test -n "$AIR_KOREA_OPEN_API_KEY"'
```
또는:
@ -132,12 +133,14 @@ kskill-run() {
| SRT 예매 | `KSKILL_SRT_ID`, `KSKILL_SRT_PASSWORD` |
| KTX 예매 | `KSKILL_KTX_ID`, `KSKILL_KTX_PASSWORD` |
| 서울 지하철 도착정보 조회 | `SEOUL_OPEN_API_KEY` |
| 사용자 위치 미세먼지 조회 | `AIR_KOREA_OPEN_API_KEY` |
## 다음에 볼 문서
- [SRT 예매 가이드](features/srt-booking.md)
- [KTX 예매 가이드](features/ktx-booking.md)
- [서울 지하철 도착정보 가이드](features/seoul-subway-arrival.md)
- [사용자 위치 미세먼지 조회 가이드](features/fine-dust-location.md)
- [보안/시크릿 정책](security-and-secrets.md)
설치 기본 흐름은 "전체 스킬 설치 → `k-skill-setup` 실행 → 개별 기능 사용" 입니다.

View file

@ -17,6 +17,8 @@
- 블루리본 지역 검색: https://www.bluer.co.kr/search/zone
- 블루리본 주변 맛집 JSON: https://www.bluer.co.kr/restaurants/map
- 서울특별시 지하철 실시간 도착정보: https://www.data.go.kr/data/15058052/openapi.do
- 에어코리아 대기오염정보: https://www.data.go.kr/data/15073861/openapi.do
- 에어코리아 측정소정보: https://www.data.go.kr/data/15073877/openapi.do
- 우체국 도로명주소 검색: https://parcel.epost.go.kr/parcel/comm/zipcode/comm_newzipcd_list.jsp
- CJ대한통운 배송조회: https://www.cjlogistics.com/ko/tool/parcel/tracking
- CJ대한통운 배송상세 JSON: https://www.cjlogistics.com/ko/tool/parcel/tracking-detail

View file

@ -3,3 +3,4 @@ KSKILL_SRT_PASSWORD=replace-me
KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
SEOUL_OPEN_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me

152
fine-dust-location/SKILL.md Normal file
View file

@ -0,0 +1,152 @@
---
name: fine-dust-location
description: 에어코리아 공식 API에서 미세먼지(PM10)와 초미세먼지(PM2.5)를 사용자 위치 또는 지역 fallback 기준으로 조회한다.
license: MIT
metadata:
category: utility
locale: ko-KR
phase: v1
---
# Fine Dust By Location
## What this skill does
사용자 위치정보(위도/경도) 또는 지역명 fallback을 바탕으로 가까운 측정소를 고른 뒤, 에어코리아 공식 OpenAPI에서 미세먼지(PM10)와 초미세먼지(PM2.5) 실측값을 조회한다.
## When to use
- "지금 내 위치 미세먼지 어때?"
- "여기 공기질 괜찮아?"
- "강남 쪽 초미세먼지 수치 알려줘"
## Prerequisites
- 에어코리아 OpenAPI key
- `sops` and `age` installed
- common setup reviewed in `../k-skill-setup/SKILL.md`
- secret policy reviewed in `../docs/security-and-secrets.md`
- Python 3
## Required secrets
- `AIR_KOREA_OPEN_API_KEY`
## Inputs
- 우선 입력: 사용자 위치 위도/경도
- fallback 입력: 지역명/행정구역 힌트 또는 측정소명
## Workflow
### 1. Stop for secure registration when the API key is missing
`AIR_KOREA_OPEN_API_KEY`, `~/.config/k-skill/secrets.env`, `~/.config/k-skill/age/keys.txt` 중 하나라도 없으면 다음 식으로 안내하고 멈춘다.
```text
이 작업에는 AIR_KOREA_OPEN_API_KEY 가 필요합니다.
값을 채팅창에 붙여 넣지 말고 ~/.config/k-skill/secrets.env.plain 에 직접 채운 뒤
sops 로 ~/.config/k-skill/secrets.env 로 암호화해 주세요.
암호화가 끝나면 plaintext 파일은 지우고 bash scripts/check-setup.sh 로 다시 확인해 주세요.
```
```bash
SOPS_AGE_KEY_FILE="$HOME/.config/k-skill/age/keys.txt" \
sops exec-env "$HOME/.config/k-skill/secrets.env" 'test -n "$AIR_KOREA_OPEN_API_KEY"'
```
### 2. Prefer the official location-first measuring-station lookup
좌표를 이미 알고 있으면 측정소정보 API의 `getNearbyMsrstnList` 로 가까운 측정소를 찾는다.
```bash
SOPS_AGE_KEY_FILE="$HOME/.config/k-skill/age/keys.txt" \
sops exec-env "$HOME/.config/k-skill/secrets.env" \
'curl -sG "http://apis.data.go.kr/B552584/MsrstnInfoInqireSvc/getNearbyMsrstnList" \
--data-urlencode "serviceKey=${AIR_KOREA_OPEN_API_KEY}" \
--data-urlencode "returnType=json" \
--data-urlencode "numOfRows=10" \
--data-urlencode "pageNo=1" \
--data-urlencode "dmX=37.5665" \
--data-urlencode "dmY=126.9780"'
```
### 3. Use the official fallback when the user cannot provide precise coordinates
현재 위치 권한이 없거나 `getNearbyMsrstnList` 결과가 비면, 같은 측정소정보 API의 `getMsrstnList` 로 지역명/행정구역 또는 측정소명 fallback을 건다.
```bash
SOPS_AGE_KEY_FILE="$HOME/.config/k-skill/age/keys.txt" \
sops exec-env "$HOME/.config/k-skill/secrets.env" \
'curl -sG "http://apis.data.go.kr/B552584/MsrstnInfoInqireSvc/getMsrstnList" \
--data-urlencode "serviceKey=${AIR_KOREA_OPEN_API_KEY}" \
--data-urlencode "returnType=json" \
--data-urlencode "numOfRows=50" \
--data-urlencode "pageNo=1" \
--data-urlencode "addr=서울 강남구"'
```
이 스킬의 fallback/폴백 규칙은 다음 순서를 기본으로 한다.
1. 위도/경도 → `getNearbyMsrstnList`
2. 지역명/행정구역 → `getMsrstnList`
3. 측정소명 직접 지정 → `getMsrstnAcctoRltmMesureDnsty`
### 4. Query the official real-time measurement API
선택한 가까운 측정소 이름으로 대기오염정보 API `getMsrstnAcctoRltmMesureDnsty` 를 호출해 PM10/PM2.5 와 등급을 가져온다.
```bash
SOPS_AGE_KEY_FILE="$HOME/.config/k-skill/age/keys.txt" \
sops exec-env "$HOME/.config/k-skill/secrets.env" \
'curl -sG "http://apis.data.go.kr/B552584/ArpltnInforInqireSvc/getMsrstnAcctoRltmMesureDnsty" \
--data-urlencode "serviceKey=${AIR_KOREA_OPEN_API_KEY}" \
--data-urlencode "returnType=json" \
--data-urlencode "numOfRows=100" \
--data-urlencode "pageNo=1" \
--data-urlencode "stationName=중구" \
--data-urlencode "dataTerm=DAILY" \
--data-urlencode "ver=1.4"'
```
### 5. Prefer the helper script for repeatable summaries
반복 실행이나 fixture 검증에는 `python3 scripts/fine_dust.py report ...` 경로를 우선한다.
```bash
python3 scripts/fine_dust.py report \
--station-file scripts/fixtures/fine-dust-stations.json \
--measurement-file scripts/fixtures/fine-dust-measurements.json \
--lat 37.5665 \
--lon 126.9780
```
실전 호출은 같은 CLI에 `--region-hint` 또는 `--station-name` fallback을 줄 수 있다.
### 6. Keep the answer compact and explicit
응답에는 아래만 먼저 정리한다.
- 가까운 측정소
- 조회 시점/조회 시각
- PM10 값과 등급
- PM2.5 값과 등급
- 좌표 기반 조회인지, 지역 fallback인지
## Done when
- 사용자 위치 또는 fallback 입력으로 가까운 측정소를 골랐다
- PM10, PM2.5, 등급, 조회 시점을 보여줬다
- 위치 자동 인식이 없을 때의 대체 흐름을 설명했다
## Failure modes
- API key 미설정
- 위치 좌표 없이 지역 힌트도 없는 경우
- nearby API 결과가 비어 지역 fallback이 필요한 경우
- 측정소명 표기 불일치
## Notes
- 실시간 값은 수시로 바뀌므로 답변에 조회 시점을 같이 적는다
- issue #17 승인 코멘트대로 두 가지 OpenAPI(측정소정보 + 대기오염정보)를 함께 사용한다

View file

@ -126,6 +126,7 @@ KSKILL_SRT_PASSWORD=replace-me
KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
SEOUL_OPEN_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
EOF
```
@ -156,6 +157,7 @@ sops 로 ~/.config/k-skill/secrets.env 로 암호화해 주세요.
- SRT: `KSKILL_SRT_ID`, `KSKILL_SRT_PASSWORD`
- KTX: `KSKILL_KTX_ID`, `KSKILL_KTX_PASSWORD`
- 서울 지하철: `SEOUL_OPEN_API_KEY`
- 사용자 위치 미세먼지 조회: `AIR_KOREA_OPEN_API_KEY`
시크릿이 비어 있다는 이유로 다른 서비스나 비공식 우회 경로를 자동 선택하지 않는다.
@ -164,7 +166,7 @@ sops 로 ~/.config/k-skill/secrets.env 로 암호화해 주세요.
```bash
SOPS_AGE_KEY_FILE="$HOME/.config/k-skill/age/keys.txt" \
sops exec-env "$HOME/.config/k-skill/secrets.env" \
'test -n "$KSKILL_SRT_ID" || test -n "$KSKILL_KTX_ID" || test -n "$SEOUL_OPEN_API_KEY"'
'test -n "$KSKILL_SRT_ID" || test -n "$KSKILL_KTX_ID" || test -n "$SEOUL_OPEN_API_KEY" || test -n "$AIR_KOREA_OPEN_API_KEY"'
```
또는 저장소에 들어있는 점검 스크립트를 쓴다.

345
scripts/fine_dust.py Executable file
View file

@ -0,0 +1,345 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import pathlib
import sys
import urllib.parse
import urllib.request
STATION_SERVICE_URL = "http://apis.data.go.kr/B552584/MsrstnInfoInqireSvc"
MEASUREMENT_SERVICE_URL = "http://apis.data.go.kr/B552584/ArpltnInforInqireSvc"
SECRET_NAME = "AIR_KOREA_OPEN_API_KEY"
GRADE_LABELS = {
"1": "좋음",
"2": "보통",
"3": "나쁨",
"4": "매우나쁨",
}
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Summarize Air Korea PM10/PM2.5 data from location or fallback hints.",
)
subparsers = parser.add_subparsers(dest="command", required=True)
report = subparsers.add_parser("report", help="build a PM10/PM2.5 report")
report.add_argument("--lat", type=float, help="WGS84 latitude")
report.add_argument("--lon", type=float, help="WGS84 longitude")
report.add_argument("--region-hint", help="fallback region/administrative-area hint")
report.add_argument("--station-name", help="explicit station name fallback")
report.add_argument("--station-file", help="offline station JSON fixture")
report.add_argument("--measurement-file", help="offline measurement JSON fixture")
report.add_argument("--json", action="store_true", help="print JSON instead of text")
return parser.parse_args(argv)
def load_json_file(path: str | os.PathLike[str]) -> dict:
return json.loads(pathlib.Path(path).read_text(encoding="utf-8"))
def extract_items(payload: dict | list) -> list[dict]:
if isinstance(payload, list):
return payload
response = payload.get("response", {})
body = response.get("body", {})
items = body.get("items", [])
if isinstance(items, dict):
return [items]
if isinstance(items, list):
return items
return []
def to_float(raw: object) -> float | None:
if raw in (None, "", "-"):
return None
try:
return float(str(raw))
except ValueError:
return None
def squared_distance(lat_a: float, lon_a: float, lat_b: float, lon_b: float) -> float:
return (lat_a - lat_b) ** 2 + (lon_a - lon_b) ** 2
def pick_station(
station_items: list[dict],
*,
lat: float | None = None,
lon: float | None = None,
region_hint: str | None = None,
station_name: str | None = None,
) -> dict:
if not station_items:
raise SystemExit("측정소 후보가 없습니다.")
if station_name:
exact_match = next((item for item in station_items if item.get("stationName") == station_name), None)
if exact_match:
return exact_match
partial_match = next(
(
item
for item in station_items
if station_name in str(item.get("stationName", "")) or station_name in str(item.get("addr", ""))
),
None,
)
if partial_match:
return partial_match
if lat is not None and lon is not None:
candidates = []
for item in station_items:
item_lat = to_float(item.get("dmX"))
item_lon = to_float(item.get("dmY"))
if item_lat is None or item_lon is None:
continue
candidates.append((squared_distance(lat, lon, item_lat, item_lon), item))
if candidates:
candidates.sort(key=lambda pair: pair[0])
return candidates[0][1]
if region_hint:
tokens = sorted({token for token in region_hint.split() if token}, key=len, reverse=True)
for token in tokens:
station_name_match = next(
(item for item in station_items if token in str(item.get("stationName", ""))),
None,
)
if station_name_match:
return station_name_match
address_match = next(
(item for item in station_items if token in str(item.get("addr", ""))),
None,
)
if address_match:
return address_match
return station_items[0]
def find_measurement(measurement_items: list[dict], station_name: str) -> dict:
exact_match = next((item for item in measurement_items if item.get("stationName") == station_name), None)
if exact_match:
return exact_match
partial_match = next(
(item for item in measurement_items if station_name in str(item.get("stationName", ""))),
None,
)
if partial_match:
return partial_match
raise SystemExit(f"측정값 응답에서 측정소 '{station_name}' 를 찾지 못했습니다.")
def grade_to_label(raw_grade: object, *, pollutant: str, value: object) -> str:
raw_text = str(raw_grade) if raw_grade not in (None, "") else ""
if raw_text in GRADE_LABELS:
return GRADE_LABELS[raw_text]
numeric_value = to_float(value)
if numeric_value is None:
return "정보없음"
thresholds = {
"pm10": [(30, "좋음"), (80, "보통"), (150, "나쁨")],
"pm25": [(15, "좋음"), (35, "보통"), (75, "나쁨")],
}[pollutant]
for threshold, label in thresholds:
if numeric_value <= threshold:
return label
return "매우나쁨"
def build_report(
*,
station_items: list[dict],
measurement_items: list[dict],
lat: float | None = None,
lon: float | None = None,
region_hint: str | None = None,
station_name: str | None = None,
) -> dict:
station = pick_station(
station_items,
lat=lat,
lon=lon,
region_hint=region_hint,
station_name=station_name,
)
measurement = find_measurement(measurement_items, station["stationName"])
lookup_mode = "coordinates" if lat is not None and lon is not None else "fallback"
return {
"station_name": station["stationName"],
"station_address": station.get("addr"),
"lookup_mode": lookup_mode,
"measured_at": measurement.get("dataTime"),
"pm10": {
"value": str(measurement.get("pm10Value", "-")),
"grade": grade_to_label(
measurement.get("pm10Grade"),
pollutant="pm10",
value=measurement.get("pm10Value"),
),
},
"pm25": {
"value": str(measurement.get("pm25Value", "-")),
"grade": grade_to_label(
measurement.get("pm25Grade"),
pollutant="pm25",
value=measurement.get("pm25Value"),
),
},
"khai_grade": grade_to_label(
measurement.get("khaiGrade"),
pollutant="pm10",
value=measurement.get("pm10Value"),
),
}
def build_missing_secret_message() -> str:
return (
f"이 작업에는 {SECRET_NAME} 가 필요합니다.\n"
"값을 채팅창에 붙여 넣지 말고 ~/.config/k-skill/secrets.env.plain 에 직접 채운 뒤\n"
"sops 로 ~/.config/k-skill/secrets.env 로 암호화해 주세요.\n"
"암호화가 끝나면 plaintext 파일은 지우고 bash scripts/check-setup.sh 로 다시 확인해 주세요."
)
def get_required_secret() -> str:
value = os.environ.get(SECRET_NAME)
if not value:
raise SystemExit(build_missing_secret_message())
return value
def fetch_json(url: str, params: dict[str, object]) -> dict:
query = urllib.parse.urlencode({key: value for key, value in params.items() if value is not None})
request_url = f"{url}?{query}"
with urllib.request.urlopen(request_url, timeout=20) as response:
return json.load(response)
def fetch_station_payload(args: argparse.Namespace) -> dict:
if args.station_file:
return load_json_file(args.station_file)
service_key = get_required_secret()
common = {
"serviceKey": service_key,
"returnType": "json",
"numOfRows": 50,
"pageNo": 1,
}
if args.lat is not None and args.lon is not None:
nearby_payload = fetch_json(
f"{STATION_SERVICE_URL}/getNearbyMsrstnList",
{
**common,
"numOfRows": 10,
"dmX": args.lat,
"dmY": args.lon,
},
)
if extract_items(nearby_payload):
return nearby_payload
if args.region_hint or args.station_name:
return fetch_json(
f"{STATION_SERVICE_URL}/getMsrstnList",
{
**common,
"addr": args.region_hint,
"stationName": args.station_name,
},
)
raise SystemExit("위도/경도 또는 region fallback 이 필요합니다.")
def fetch_measurement_payload(args: argparse.Namespace, station_name: str) -> dict:
if args.measurement_file:
return load_json_file(args.measurement_file)
service_key = get_required_secret()
return fetch_json(
f"{MEASUREMENT_SERVICE_URL}/getMsrstnAcctoRltmMesureDnsty",
{
"serviceKey": service_key,
"returnType": "json",
"numOfRows": 100,
"pageNo": 1,
"stationName": station_name,
"dataTerm": "DAILY",
"ver": "1.4",
},
)
def render_text(report: dict) -> str:
return "\n".join(
[
f"측정소: {report['station_name']}",
f"주소: {report['station_address']}",
f"조회 시각: {report['measured_at']}",
f"조회 방식: {report['lookup_mode']}",
f"PM10: {report['pm10']['value']} ({report['pm10']['grade']})",
f"PM2.5: {report['pm25']['value']} ({report['pm25']['grade']})",
f"통합대기등급: {report['khai_grade']}",
],
)
def command_report(args: argparse.Namespace) -> None:
station_payload = fetch_station_payload(args)
station_items = extract_items(station_payload)
station = pick_station(
station_items,
lat=args.lat,
lon=args.lon,
region_hint=args.region_hint,
station_name=args.station_name,
)
measurement_payload = fetch_measurement_payload(args, station["stationName"])
report = build_report(
station_items=station_items,
measurement_items=extract_items(measurement_payload),
lat=args.lat,
lon=args.lon,
region_hint=args.region_hint,
station_name=station["stationName"],
)
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2))
return
print(render_text(report))
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
if args.command == "report":
command_report(args)
return 0
raise SystemExit(f"unsupported command: {args.command}")
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,35 @@
{
"response": {
"body": {
"items": [
{
"stationName": "중구",
"dataTime": "2026-03-27 21:00",
"pm10Value": "42",
"pm10Grade": "2",
"pm25Value": "19",
"pm25Grade": "2",
"khaiGrade": "2"
},
{
"stationName": "종로구",
"dataTime": "2026-03-27 21:00",
"pm10Value": "57",
"pm10Grade": "2",
"pm25Value": "31",
"pm25Grade": "2",
"khaiGrade": "2"
},
{
"stationName": "강남구",
"dataTime": "2026-03-27 21:00",
"pm10Value": "81",
"pm10Grade": "3",
"pm25Value": "44",
"pm25Grade": "3",
"khaiGrade": "3"
}
]
}
}
}

View file

@ -0,0 +1,26 @@
{
"response": {
"body": {
"items": [
{
"stationName": "중구",
"addr": "서울 중구 서소문로 124",
"dmX": "37.5640",
"dmY": "126.9757"
},
{
"stationName": "종로구",
"addr": "서울 종로구 종로35가길 19",
"dmX": "37.5720",
"dmY": "127.0050"
},
{
"stationName": "강남구",
"addr": "서울 강남구 학동로 426",
"dmX": "37.5179",
"dmY": "127.0473"
}
]
}
}
}

View file

@ -542,3 +542,67 @@ test("blue-ribbon-nearby package README stays aligned with the location-first an
assert.match(packageReadme, /https:\/\/www\.bluer\.co\.kr\/restaurants\/map/);
assert.match(packageReadme, /searchNearbyByLocationQuery/);
});
test("repository docs advertise the fine-dust-location 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 setup = read(path.join("docs", "setup.md"));
const security = read(path.join("docs", "security-and-secrets.md"));
const secretsExample = read(path.join("examples", "secrets.env.example"));
const featureDocPath = path.join(repoRoot, "docs", "features", "fine-dust-location.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/fine-dust-location.md to exist");
assert.match(readme, /\| 사용자 위치 미세먼지 조회 \|/);
assert.match(readme, /\[사용자 위치 미세먼지 조회 가이드\]\(docs\/features\/fine-dust-location\.md\)/);
assert.match(install, /--skill fine-dust-location/);
assert.match(roadmap, /사용자 위치 미세먼지 조회 스킬 출시/);
assert.match(sources, /에어코리아 대기오염정보: https:\/\/www\.data\.go\.kr\/data\/15073861\/openapi\.do/);
assert.match(sources, /에어코리아 측정소정보: https:\/\/www\.data\.go\.kr\/data\/15073877\/openapi\.do/);
assert.match(setup, /AIR_KOREA_OPEN_API_KEY/);
assert.match(security, /AIR_KOREA_OPEN_API_KEY/);
assert.match(secretsExample, /^AIR_KOREA_OPEN_API_KEY=replace-me$/m);
});
test("fine-dust-location skill documents the official two-api flow and fallback handling", () => {
const skillPath = path.join(repoRoot, "fine-dust-location", "SKILL.md");
assert.ok(fs.existsSync(skillPath), "expected fine-dust-location/SKILL.md to exist");
const skill = read(path.join("fine-dust-location", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "fine-dust-location.md"));
assert.match(skill, /^name: fine-dust-location$/m);
assert.match(skill, /^description: .*미세먼지.*초미세먼지.*위치.*$/m);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /AIR_KOREA_OPEN_API_KEY/);
assert.match(doc, /B552584\/MsrstnInfoInqireSvc\/getNearbyMsrstnList/);
assert.match(doc, /B552584\/MsrstnInfoInqireSvc\/getMsrstnList/);
assert.match(doc, /B552584\/ArpltnInforInqireSvc\/getMsrstnAcctoRltmMesureDnsty/);
assert.match(doc, /PM10/);
assert.match(doc, /PM2\.5|PM25/);
assert.match(doc, /위도/);
assert.match(doc, /경도/);
assert.match(doc, /행정구역|지역명/);
assert.match(doc, /fallback|폴백|대체 흐름/i);
assert.match(doc, /가까운 측정소/);
assert.match(doc, /조회 시각|조회 시점/);
assert.match(doc, /python3 scripts\/fine_dust\.py/);
}
});
test("fine-dust helper python regression tests pass", () => {
const result = childProcess.spawnSync(
"python3",
["-m", "unittest", "discover", "-s", "scripts", "-p", "test_fine_dust.py"],
{ cwd: repoRoot, encoding: "utf8" },
);
assert.equal(
result.status,
0,
`expected python fine-dust helper regression tests to pass\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
);
});

90
scripts/test_fine_dust.py Normal file
View file

@ -0,0 +1,90 @@
import io
import json
import pathlib
import unittest
from contextlib import redirect_stdout
import fine_dust
FIXTURES = pathlib.Path(__file__).with_name("fixtures")
def load_fixture(name):
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
class FineDustTests(unittest.TestCase):
def test_pick_station_prefers_nearest_station_for_coordinates(self):
stations = load_fixture("fine-dust-stations.json")
station = fine_dust.pick_station(
fine_dust.extract_items(stations),
lat=37.5665,
lon=126.9780,
)
self.assertEqual(station["stationName"], "중구")
def test_pick_station_prefers_specific_region_token_over_generic_city_token(self):
stations = load_fixture("fine-dust-stations.json")
station = fine_dust.pick_station(
fine_dust.extract_items(stations),
region_hint="서울 강남구",
)
self.assertEqual(station["stationName"], "강남구")
def test_pick_station_falls_back_to_region_hint_without_coordinates(self):
stations = load_fixture("fine-dust-stations.json")
station = fine_dust.pick_station(
fine_dust.extract_items(stations),
region_hint="강남",
)
self.assertEqual(station["stationName"], "강남구")
def test_build_report_combines_station_and_measurement_summary(self):
stations = load_fixture("fine-dust-stations.json")
measurements = load_fixture("fine-dust-measurements.json")
report = fine_dust.build_report(
station_items=fine_dust.extract_items(stations),
measurement_items=fine_dust.extract_items(measurements),
lat=37.5665,
lon=126.9780,
)
self.assertEqual(report["station_name"], "중구")
self.assertEqual(report["pm10"], {"value": "42", "grade": "보통"})
self.assertEqual(report["pm25"], {"value": "19", "grade": "보통"})
self.assertEqual(report["measured_at"], "2026-03-27 21:00")
def test_cli_report_supports_fixture_inputs(self):
station_path = FIXTURES / "fine-dust-stations.json"
measurement_path = FIXTURES / "fine-dust-measurements.json"
stdout = io.StringIO()
with redirect_stdout(stdout):
fine_dust.main([
"report",
"--station-file",
str(station_path),
"--measurement-file",
str(measurement_path),
"--lat",
"37.5665",
"--lon",
"126.9780",
])
rendered = stdout.getvalue()
self.assertIn("측정소: 중구", rendered)
self.assertIn("PM10: 42 (보통)", rendered)
self.assertIn("PM2.5: 19 (보통)", rendered)
if __name__ == "__main__":
unittest.main()