mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
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:
parent
8aea595296
commit
8c86d54b1f
15 changed files with 828 additions and 5 deletions
|
|
@ -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)
|
||||
|
|
|
|||
95
docs/features/fine-dust-location.md
Normal file
95
docs/features/fine-dust-location.md
Normal 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 값이 `-` 이거나 비정상이면 등급도 함께 재확인합니다
|
||||
- 위치 기반이라고 해도 실제 기준은 “가까운 측정소” 이므로 주소 중심점과 오차가 있을 수 있습니다
|
||||
|
|
@ -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`
|
||||
|
||||
관련 문서:
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
- KBO 경기 결과
|
||||
- 로또 당첨번호
|
||||
- 서울 지하철 도착 정보
|
||||
- 사용자 위치 미세먼지 조회 스킬 출시
|
||||
- 우편번호 검색
|
||||
- 근처 블루리본 맛집 스킬 출시
|
||||
- 택배 배송조회 스킬 출시 (CJ대한통운 / 우체국)
|
||||
|
|
@ -62,7 +63,7 @@
|
|||
- 장점: 기본 배송조회는 `delivery-tracking` 으로 선출시했고, 다음 단계는 예약/반품/추가 택배사 확장으로 자연스럽게 이어진다
|
||||
- 이유: 한국 생활에서 반복 빈도가 높은 작업이라 조회 다음 액션까지 묶을 가치가 크다
|
||||
|
||||
#### 미세먼지/황사/대기질 알림
|
||||
#### 미세먼지/황사 예보·알림 확장
|
||||
|
||||
- 장점: 오늘/내일/모레 대기정보와 예보, 하루 4회 수준의 예보 갱신 같은 한국형 수요에 잘 맞는다
|
||||
- 이유: 한국 로컬 생활 스킬로 차별화가 쉽다
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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` 실행 → 개별 기능 사용" 입니다.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
152
fine-dust-location/SKILL.md
Normal 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(측정소정보 + 대기오염정보)를 함께 사용한다
|
||||
|
|
@ -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
345
scripts/fine_dust.py
Executable 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())
|
||||
35
scripts/fixtures/fine-dust-measurements.json
Normal file
35
scripts/fixtures/fine-dust-measurements.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
26
scripts/fixtures/fine-dust-stations.json
Normal file
26
scripts/fixtures/fine-dust-stations.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
90
scripts/test_fine_dust.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue