Make the fine-dust proxy easier to consume than the upstream APIs

The fine-dust lane now treats the public proxy as the default surface,
keeps a simple summarized report endpoint, and also exposes a narrow
AirKorea passthrough shape so callers can reuse upstream query patterns
without carrying service keys on the client side.

The skill instructions were trimmed down so the default path is obvious,
region-name guidance stays visible, and detailed implementation notes
move into feature docs instead of bloating the primary skill surface.

Constraint: Free-API proxy endpoints are intentionally public and must avoid embedding upstream secrets in the repo
Constraint: AirKorea station-info access can return 403 even when measurement access succeeds, so the report path needs a measurement-only fallback
Rejected: Keep proxy auth via shared token | contradicts the intended public free-API proxy policy
Rejected: Force all callers onto the summary endpoint only | passthrough compatibility is useful for direct HTTP consumers
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep the proxy allowlist narrow; if new upstream routes are exposed, document them explicitly rather than turning this into a generic open proxy
Tested: node --test scripts/skill-docs.test.js; npm run test --workspace k-skill-proxy; python3 -m unittest discover -s scripts -p test_fine_dust.py; live curls against /health, /v1/fine-dust/report, and /B552584/ArpltnInforInqireSvc/getMsrstnAcctoRltmMesureDnsty
Not-tested: Fresh reboot validation of PM2/cloudflared persistence after the latest code-only changes
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-03-28 17:14:18 +09:00
commit a3ef6ffac6
19 changed files with 2088 additions and 125 deletions

View file

@ -23,3 +23,10 @@ These rules are repo-specific and apply to everything under this directory.
- Use `~/.claude/skills/<skill-name>` for Claude Code and `~/.agents/skills/<skill-name>` for agents-compatible home installs.
- Respect existing home-directory indirection such as symlinks when syncing `~/.agents/skills`.
- Do **not** create repo-local `.claude` or `.agents` directories for skill installation unless the user explicitly asks for a repository-local test fixture.
## Free API proxy policy
- The built-in `k-skill-proxy` is for **free APIs only**.
- Default posture: public read-only endpoint, **no proxy auth by default**.
- Keep free-API proxy surfaces narrow, allowlisted, cache-backed, and rate-limited.
- If abuse or operational issues appear later, add stricter controls then instead of preemptively requiring auth.

View file

@ -7,6 +7,8 @@ SRT, KTX, KBO, 로또, 당근, 쿠팡, 카톡, 정부24, 홈택스 등등 귀찮
Claude code, codex, opencode 등 각종 코딩 에이전트 지원합니다.
추가 클라이언트 API 레이어는 불필요합니다. 필요한 경우 `k-skill-proxy` 같은 프록시 서버에 HTTP 요청만 넣으면 됩니다.
## 잠깐만~~~
한국인이면 깃허브 스타 눌러줍시다.
@ -20,7 +22,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) |
| 사용자 위치 미세먼지 조회 | `k-skill-proxy` 로 현재 위치 또는 지역 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) |
@ -45,6 +47,7 @@ Claude code, codex, opencode 등 각종 코딩 에이전트 지원합니다.
| [설치 방법](docs/install.md) | 패키지 설치, 선택 설치, 로컬 테스트 방법 |
| [공통 설정 가이드](docs/setup.md) | `sops + age` 설치, age key 생성, 공통 secrets 파일 준비 |
| [보안/시크릿 정책](docs/security-and-secrets.md) | 인증 정보 저장 원칙, 금지 패턴, 표준 환경변수 이름 |
| [k-skill 프록시 서버 가이드](docs/features/k-skill-proxy.md) | 무료 API를 프록시 서버로 바로 호출하는 방법 |
| [릴리스/배포 가이드](docs/releasing.md) | npm Changesets, Python release-please, trusted publishing 운영 규칙 |
| [로드맵](docs/roadmap.md) | 현재 포함 기능과 다음 후보 |
| [출처/참고 표면](docs/sources.md) | 설계 시 참고한 공개 라이브러리와 공식 문서 |

View file

@ -10,10 +10,17 @@
- [공통 설정 가이드](../setup.md) 완료
- [보안/시크릿 정책](../security-and-secrets.md) 확인
- 에어코리아 OpenAPI key
- `k-skill-proxy` 또는 에어코리아 OpenAPI key
## 필요한 시크릿
클라이언트 기본값:
- 기본 external proxy URL: `https://k-skill-proxy.nomadamas.org`
- `KSKILL_PROXY_BASE_URL` 는 override 가 필요할 때만 사용
프록시 없이 direct fallback 으로 쓸 때만:
- `AIR_KOREA_OPEN_API_KEY`
## 입력값
@ -23,11 +30,32 @@
## 기본 흐름
1. 좌표가 있으면 입력 위도/경도(WGS84)를 에어코리아 nearby 조회가 요구하는 **TM 좌표(중부원점)** 로 먼저 변환합니다.
2. 변환된 `tmX`/`tmY` 로 측정소정보 API `getNearbyMsrstnList` 를 호출해 가까운 측정소를 찾습니다.
3. 좌표를 못 받거나 nearby 결과가 비면 측정소정보 API `getMsrstnList` 로 지역명/행정구역 fallback을 사용합니다.
4. 선택된 측정소 이름으로 대기오염정보 API `getMsrstnAcctoRltmMesureDnsty` 를 호출합니다.
5. PM10, PM2.5, 등급, 조회 시점/조회 시각을 함께 요약합니다.
1. `KSKILL_PROXY_BASE_URL` 가 있으면 먼저 `k-skill-proxy``/v1/fine-dust/report` endpoint 를 호출합니다.
2. 프록시가 없을 때만 입력 위도/경도(WGS84)를 에어코리아 nearby 조회가 요구하는 **TM 좌표(중부원점)** 로 먼저 변환합니다.
3. 변환된 `tmX`/`tmY` 로 측정소정보 API `getNearbyMsrstnList` 를 호출해 가까운 측정소를 찾습니다.
4. 좌표를 못 받거나 nearby 결과가 비면 측정소정보 API `getMsrstnList` 로 지역명/행정구역 fallback을 사용합니다.
5. 선택된 측정소 이름으로 대기오염정보 API `getMsrstnAcctoRltmMesureDnsty` 를 호출합니다.
6. PM10, PM2.5, 등급, 조회 시점/조회 시각을 함께 요약합니다.
프록시 예시:
```bash
SOPS_AGE_KEY_FILE="$HOME/.config/k-skill/age/keys.txt" \
sops exec-env "$HOME/.config/k-skill/secrets.env" \
'python3 scripts/fine_dust.py report --region-hint "서울 강남구" --json'
```
원본 AirKorea endpoint 형태를 거의 그대로 쓰고 싶으면 passthrough endpoint 도 사용할 수 있습니다. 별도 client API 는 불필요하고, 프록시가 `serviceKey` 만 서버에서 주입합니다.
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/B552584/ArpltnInforInqireSvc/getMsrstnAcctoRltmMesureDnsty' \
--data-urlencode 'returnType=json' \
--data-urlencode 'numOfRows=1' \
--data-urlencode 'pageNo=1' \
--data-urlencode 'stationName=강남구' \
--data-urlencode 'dataTerm=DAILY' \
--data-urlencode 'ver=1.4'
```
## 예시
@ -99,3 +127,4 @@ python3 scripts/fine_dust.py report \
- PM10/PM2.5 값이 `-` 이거나 비정상이면 등급도 함께 재확인합니다
- API 가 `khaiGrade` 를 비워 보내면 통합대기등급은 `정보없음` 으로 표시합니다
- 위치 기반이라고 해도 실제 기준은 “가까운 측정소” 이므로 주소 중심점과 오차가 있을 수 있습니다
- hosted 모드에서는 upstream AirKorea key 를 클라이언트에 배포하지 않고 proxy 에만 둡니다

View file

@ -0,0 +1,72 @@
# k-skill 프록시 서버 가이드
## 이 기능으로 할 수 있는 일
- AirKorea 같은 무료/공공 API key를 서버에만 보관
- `k-skill` 클라이언트는 프록시만 호출
- 캐시, 인증, rate limit, 로깅을 한곳에서 통제
## 기본 구조
```text
client/skill -> k-skill-proxy -> upstream public API
```
현재 기본 엔드포인트는 아래 둘입니다.
- `GET /health`
- `GET /v1/fine-dust/report`
- `GET /B552584/:service/:operation` (허용된 AirKorea route passthrough)
## 권장 환경변수
클라이언트(스킬) 쪽:
- `KSKILL_PROXY_BASE_URL=https://k-skill-proxy.nomadamas.org`
프록시 서버 쪽:
- `AIR_KOREA_OPEN_API_KEY=...`
- `KSKILL_PROXY_PORT=4020`
## PM2 + cloudflared
1. `pm2 start ecosystem.config.cjs`
2. `pm2 save`
3. `pm2 startup` 출력대로 launchd 등록
4. Cloudflare Tunnel ingress 에 `k-skill-proxy.nomadamas.org -> http://localhost:4020` 추가
## 기본 공개 정책
- 이 프록시는 **무료 API만** 붙인다.
- 기본값은 **무인증 공개 endpoint** 다.
- 대신 read-only / allowlisted endpoint / cache / rate limit 을 유지한다.
- 문제가 생기면 그때 인증이나 더 강한 방어를 덧붙인다.
## 사용법
추가 client API 레이어는 불필요합니다. 필요한 쿼리를 그대로 프록시에 넣으면 되고, 프록시가 upstream API key 만 서버에서 주입합니다.
요약 endpoint:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/fine-dust/report' \
--data-urlencode 'regionHint=서울 강남구'
```
AirKorea passthrough endpoint:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/B552584/ArpltnInforInqireSvc/getMsrstnAcctoRltmMesureDnsty' \
--data-urlencode 'returnType=json' \
--data-urlencode 'numOfRows=1' \
--data-urlencode 'pageNo=1' \
--data-urlencode 'stationName=강남구' \
--data-urlencode 'dataTerm=DAILY' \
--data-urlencode 'ver=1.4'
```
## 주의할 점
- upstream key는 프록시 서버에서만 관리합니다.
- client 쪽에는 upstream API key를 배포하지 않습니다.

View file

@ -41,6 +41,7 @@ KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
SEOUL_OPEN_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=https://k-skill-proxy.nomadamas.org
```
실행은 항상 다음 패턴으로 한다.
@ -95,6 +96,7 @@ sops exec-env "$HOME/.config/k-skill/secrets.env" '<command>'
- `KSKILL_KTX_PASSWORD`
- `SEOUL_OPEN_API_KEY`
- `AIR_KOREA_OPEN_API_KEY`
- `KSKILL_PROXY_BASE_URL`
## Why sops plus age

View file

@ -74,6 +74,7 @@ KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
SEOUL_OPEN_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=https://k-skill-proxy.nomadamas.org
EOF
```
@ -133,7 +134,7 @@ 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` |
| 사용자 위치 미세먼지 조회 | `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY` |
## 다음에 볼 문서

16
ecosystem.config.cjs Normal file
View file

@ -0,0 +1,16 @@
module.exports = {
apps: [
{
name: "k-skill-proxy",
cwd: __dirname,
script: "./scripts/run-k-skill-proxy.sh",
interpreter: "/bin/bash",
exec_mode: "fork",
autorestart: true,
watch: false,
env: {
NODE_ENV: "production"
}
}
]
};

View file

@ -1,6 +1,6 @@
---
name: fine-dust-location
description: 에어코리아 공식 API에서 미세먼지(PM10)와 초미세먼지(PM2.5)를 사용자 위치 또는 지역 fallback 기준으로 조회한다.
description: 에어코리아 기반 미세먼지/초미세먼지를 지역명 또는 위치 힌트로 조회한다. 기본 경로는 k-skill-proxy의 report endpoint다.
license: MIT
metadata:
category: utility
@ -12,147 +12,70 @@ metadata:
## What this skill does
사용자 위치정보(위도/경도) 또는 지역명 fallback을 바탕으로 가까운 측정소를 고른 뒤, 에어코리아 공식 OpenAPI에서 미세먼지(PM10)와 초미세먼지(PM2.5) 실측값을 조회한다.
기본적으로 `https://k-skill-proxy.nomadamas.org/v1/fine-dust/report` 로 요청해서 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
- 우선 입력: 사용자 위치 위도/경도(WGS84)
- fallback 입력: 지역명/행정구역 힌트 또는 측정소명
- 우선 입력: 위도/경도(WGS84)
- 일반 fallback: 지역명/행정구역 힌트
- 마지막 fallback: 측정소명
## Workflow
## Region naming convention
### 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 로 다시 확인해 주세요.
```
여러 토큰이 들어오면 helper / proxy 는 보통 **가장 구체적인 토큰**을 우선 본다. 예: `서울 강남구``강남구`.
## Default path
추가 client API 레이어는 불필요하다. 그냥 프록시 서버에 HTTP 요청만 넣으면 된다.
```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"'
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/fine-dust/report' \
--data-urlencode 'regionHint=서울 강남구'
```
### 2. Prefer the official location-first measuring-station lookup
좌표를 이미 알고 있으면 먼저 위도/경도(WGS84)를 에어코리아 nearby 조회가 요구하는 **TM 좌표(중부원점)** 로 바꾼 뒤, 측정소정보 API의 `getNearbyMsrstnList` 로 가까운 측정소를 찾는다.
스크립트 helper 도 같은 report endpoint 를 기본 경로로 사용한다.
```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 "tmX=198245.053183" \
--data-urlencode "tmY=451586.837879"'
python3 scripts/fine_dust.py report --region-hint '서울 강남구' --json
```
`getNearbyMsrstnList` 는 WGS84 위도/경도를 직접 받지 않는다. `scripts/fine_dust.py` 는 사용자 좌표를 TM 좌표로 변환한 뒤 `tmX`/`tmY` 로 nearby 조회를 호출한다. 같은 기술문서에 `getTMStdrCrdnt` 도 있지만, 그 기능은 읍면동명 기준 TM 조회이므로 이 스킬의 위치-first 경로에서는 직접 WGS84→TM 변환을 사용한다.
## Detailed API paths
### 3. Use the official fallback when the user cannot provide precise coordinates
원본 AirKorea와 비슷한 passthrough 경로(`/B552584/...`)나 direct fallback 상세는 아래 문서만 참고한다.
현재 위치 권한이 없거나 `getNearbyMsrstnList` 결과가 비면, 같은 측정소정보 API의 `getMsrstnList` 로 지역명/행정구역 또는 측정소명 fallback을 건다.
- `docs/features/fine-dust-location.md`
- `docs/features/k-skill-proxy.md`
```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. 위도/경도(WGS84) → TM 좌표 변환 → `getNearbyMsrstnList`
2. 지역명/행정구역 → `getMsrstnList`
3. 측정소명 직접 지정 → `getMsrstnAcctoRltmMesureDnsty`
`getMsrstnList` 가 빈 응답이어도 `--station-name` 이 있으면 helper 는 같은 이름으로 `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
## Keep the answer compact
응답에는 아래만 먼저 정리한다.
- 가까운 측정소
- 조회 시점/조회 시각
- 측정소
- 조회 시각
- PM10 값과 등급
- PM2.5 값과 등급
- 좌표 기반 조회인지, 지역 fallback인지
- `khaiGrade` 가 비어 있으면 통합대기등급은 `정보없음`
## Done when
- 사용자 위치 또는 fallback 입력으로 가까운 측정소를 골랐다
- PM10, PM2.5, 등급, 조회 시점을 보여줬다
- 위치 자동 인식이 없을 때의 대체 흐름을 설명했다
- 통합대기등급
- 조회 방식(`coordinates` 또는 `fallback`)
## Failure modes
- API key 미설정
- 위치 좌표 없이 지역 힌트도 없는 경우
- nearby API 결과가 비어 지역 fallback이 필요한 경우
- nearby API 에 raw 위도/경도를 넘겨 잘못된 측정소를 고르는 경우
- 측정소명 표기 불일치
- regionHint 없이도 정확한 지역을 추정해야 하는 경우
- 프록시 서버가 내려가 있거나 upstream key가 비어 있는 경우
- 측정소명과 지역명이 달라 직접 fallback 이 필요한 경우
## Notes
- 실시간 값은 수시로 바뀌므로 답변에 조회 시점을 같이 적는다
- issue #17 승인 코멘트대로 두 가지 OpenAPI(측정소정보 + 대기오염정보)를 함께 사용한다
- 기본 경로는 항상 `k-skill-proxy.nomadamas.org` 의 report endpoint 다.
- passthrough / direct AirKorea 구현 세부는 스킬 본문에 길게 반복하지 않는다.
- free API 프록시는 공개 endpoint 를 기본으로 둔다.

596
package-lock.json generated
View file

@ -269,6 +269,117 @@
"prettier": "^2.7.1"
}
},
"node_modules/@fastify/ajv-compiler": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
"integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"ajv": "^8.12.0",
"ajv-formats": "^3.0.1",
"fast-uri": "^3.0.0"
}
},
"node_modules/@fastify/error": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
"integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/fast-json-stringify-compiler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz",
"integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"fast-json-stringify": "^6.0.0"
}
},
"node_modules/@fastify/forwarded": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz",
"integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/merge-json-schemas": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
"integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/@fastify/proxy-addr": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
"integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/forwarded": "^3.0.0",
"ipaddr.js": "^2.1.0"
}
},
"node_modules/@inquirer/external-editor": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz",
@ -401,6 +512,12 @@
"node": ">= 8"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
@ -411,6 +528,45 @@
"undici-types": "~6.21.0"
}
},
"node_modules/abstract-logging": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
"license": "MIT"
},
"node_modules/ajv": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ansi-colors": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
@ -448,6 +604,35 @@
"node": ">=8"
}
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/avvio": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz",
"integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/error": "^4.0.0",
"fastq": "^1.17.1"
}
},
"node_modules/better-path-resolve": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/better-path-resolve/-/better-path-resolve-1.0.0.tgz",
@ -485,6 +670,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -504,6 +702,15 @@
"resolved": "packages/daiso-product-search",
"link": true
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-indent": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz",
@ -562,6 +769,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-decode-uri-component": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
"integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@ -579,11 +798,92 @@
"node": ">=8.6.0"
}
},
"node_modules/fast-json-stringify": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz",
"integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/merge-json-schemas": "^0.2.0",
"ajv": "^8.12.0",
"ajv-formats": "^3.0.1",
"fast-uri": "^3.0.0",
"json-schema-ref-resolver": "^3.0.0",
"rfdc": "^1.2.0"
}
},
"node_modules/fast-querystring": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz",
"integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==",
"license": "MIT",
"dependencies": {
"fast-decode-uri-component": "^1.0.1"
}
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/fastify": {
"version": "5.8.4",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.4.tgz",
"integrity": "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/ajv-compiler": "^4.0.5",
"@fastify/error": "^4.0.0",
"@fastify/fast-json-stringify-compiler": "^5.0.0",
"@fastify/proxy-addr": "^5.0.0",
"abstract-logging": "^2.0.1",
"avvio": "^9.0.0",
"fast-json-stringify": "^6.0.0",
"find-my-way": "^9.0.0",
"light-my-request": "^6.0.0",
"pino": "^9.14.0 || ^10.1.0",
"process-warning": "^5.0.0",
"rfdc": "^1.3.1",
"secure-json-parse": "^4.0.0",
"semver": "^7.6.0",
"toad-cache": "^3.7.0"
}
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
@ -602,6 +902,20 @@
"node": ">=8"
}
},
"node_modules/find-my-way": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz",
"integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-querystring": "^1.0.0",
"safe-regex2": "^5.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
@ -709,6 +1023,15 @@
"node": ">= 4"
}
},
"node_modules/ipaddr.js": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
"integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@ -785,6 +1108,31 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/json-schema-ref-resolver": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",
"integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
@ -799,6 +1147,47 @@
"resolved": "packages/k-lotto",
"link": true
},
"node_modules/k-skill-proxy": {
"resolved": "packages/k-skill-proxy",
"link": true
},
"node_modules/light-my-request": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
"integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause",
"dependencies": {
"cookie": "^1.0.1",
"process-warning": "^4.0.0",
"set-cookie-parser": "^2.6.0"
}
},
"node_modules/light-my-request/node_modules/process-warning": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz",
"integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@ -853,6 +1242,15 @@
"node": ">=4"
}
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/outdent": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/outdent/-/outdent-0.5.0.tgz",
@ -992,6 +1390,43 @@
"node": ">=6"
}
},
"node_modules/pino": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^4.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/prettier": {
"version": "2.8.8",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz",
@ -1008,6 +1443,22 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/quansync": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
@ -1046,6 +1497,12 @@
],
"license": "MIT"
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/read-yaml-file": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/read-yaml-file/-/read-yaml-file-1.1.0.tgz",
@ -1086,6 +1543,24 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve-from": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
@ -1096,17 +1571,31 @@
"node": ">=8"
}
},
"node_modules/ret": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz",
"integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@ -1131,6 +1620,37 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/safe-regex2": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.0.tgz",
"integrity": "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"ret": "~0.5.0"
},
"bin": {
"safe-regex2": "bin/safe-regex2.js"
}
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@ -1138,11 +1658,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/secure-json-parse": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
"integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@ -1151,6 +1686,12 @@
"node": ">=10"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -1197,6 +1738,15 @@
"node": ">=8"
}
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/spawndamnit": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/spawndamnit/-/spawndamnit-3.0.1.tgz",
@ -1208,6 +1758,15 @@
"signal-exit": "^4.0.1"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
@ -1251,6 +1810,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/thread-stream": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -1264,6 +1835,15 @@
"node": ">=8.0"
}
},
"node_modules/toad-cache": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
"integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@ -1331,6 +1911,16 @@
"engines": {
"node": ">=18"
}
},
"packages/k-skill-proxy": {
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"fastify": "^5.3.3"
},
"engines": {
"node": ">=18"
}
}
}
}

View file

@ -0,0 +1,31 @@
# k-skill-proxy
`k-skill`용 Fastify 기반 프록시 서버입니다. 지금은 AirKorea 미세먼지 조회를 먼저 감싸고, 이후 무료/공공 API adapter를 추가하는 베이스로 씁니다.
## 현재 제공 엔드포인트
- `GET /health`
- `GET /v1/fine-dust/report`
## 환경변수
- `AIR_KOREA_OPEN_API_KEY` — 프록시 서버 쪽 AirKorea upstream key
- `KSKILL_PROXY_HOST` — 기본 `127.0.0.1`
- `KSKILL_PROXY_PORT` — 기본 `4020`
- `KSKILL_PROXY_CACHE_TTL_MS` — 기본 `300000`
- `KSKILL_PROXY_RATE_LIMIT_WINDOW_MS` — 기본 `60000`
- `KSKILL_PROXY_RATE_LIMIT_MAX` — 기본 `60`
기본 정책은 **무료 API 공개 프록시 = 무인증** 이다. 대신 endpoint scope 를 좁게 유지하고, cache + rate limit 으로 남용을 늦춘다.
## 로컬 실행
```bash
SOPS_AGE_KEY_FILE="$HOME/.config/k-skill/age/keys.txt" \
sops exec-env "$HOME/.config/k-skill/secrets.env" \
'node packages/k-skill-proxy/src/server.js'
```
## PM2 실행
루트의 `ecosystem.config.cjs` + `scripts/run-k-skill-proxy.sh` 조합을 사용하면 재부팅 이후에도 같은 encrypted secrets 경로로 다시 올라옵니다.

View file

@ -0,0 +1,18 @@
{
"name": "k-skill-proxy",
"version": "0.1.0",
"private": true,
"description": "Fastify proxy for k-skill upstream APIs",
"license": "MIT",
"main": "src/server.js",
"engines": {
"node": ">=18"
},
"scripts": {
"lint": "node --check src/airkorea.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/server.test.js",
"test": "node --test"
},
"dependencies": {
"fastify": "^5.3.3"
}
}

View file

@ -0,0 +1,513 @@
const STATION_SERVICE_URL = "http://apis.data.go.kr/B552584/MsrstnInfoInqireSvc";
const MEASUREMENT_SERVICE_URL = "http://apis.data.go.kr/B552584/ArpltnInforInqireSvc";
const WGS84_A = 6378137.0;
const WGS84_F = 1 / 298.257223563;
const BESSEL_A = 6377397.155;
const BESSEL_F = 1 / 299.1528128;
const AIR_KOREA_TM_LAT0 = degreesToRadians(38.0);
const AIR_KOREA_TM_LON0 = degreesToRadians(127.0);
const AIR_KOREA_TM_FALSE_EASTING = 200000.0;
const AIR_KOREA_TM_FALSE_NORTHING = 500000.0;
const AIR_KOREA_TM_SCALE = 1.0;
const AIR_KOREA_WGS84_TO_BESSEL = [146.43, -507.89, -681.46];
const GRADE_LABELS = {
"1": "좋음",
"2": "보통",
"3": "나쁨",
"4": "매우나쁨"
};
function degreesToRadians(value) {
return (value * Math.PI) / 180;
}
function extractItems(payload) {
if (Array.isArray(payload)) {
return payload;
}
const items = payload?.response?.body?.items;
if (Array.isArray(items)) {
return items;
}
if (items && typeof items === "object") {
return [items];
}
return [];
}
function toFloat(raw) {
if (raw === null || raw === undefined || raw === "" || raw === "-") {
return null;
}
const value = Number(raw);
return Number.isFinite(value) ? value : null;
}
function squaredDistance(latA, lonA, latB, lonB) {
return (latA - latB) ** 2 + (lonA - lonB) ** 2;
}
function meridionalArc(phi, { semiMajorAxis, eccentricitySquared }) {
const e2 = eccentricitySquared;
return semiMajorAxis * (
(1 - e2 / 4 - (3 * e2 ** 2) / 64 - (5 * e2 ** 3) / 256) * phi -
((3 * e2) / 8 + (3 * e2 ** 2) / 32 + (45 * e2 ** 3) / 1024) * Math.sin(2 * phi) +
((15 * e2 ** 2) / 256 + (45 * e2 ** 3) / 1024) * Math.sin(4 * phi) -
((35 * e2 ** 3) / 3072) * Math.sin(6 * phi)
);
}
function wgs84ToBessel(latitude, longitude) {
const [dx, dy, dz] = AIR_KOREA_WGS84_TO_BESSEL;
const sourceE2 = 2 * WGS84_F - WGS84_F ** 2;
const targetE2 = 2 * BESSEL_F - BESSEL_F ** 2;
const latRad = degreesToRadians(latitude);
const lonRad = degreesToRadians(longitude);
const sinLat = Math.sin(latRad);
const cosLat = Math.cos(latRad);
const primeVerticalRadius = WGS84_A / Math.sqrt(1 - sourceE2 * sinLat * sinLat);
const x = primeVerticalRadius * cosLat * Math.cos(lonRad) + dx;
const y = primeVerticalRadius * cosLat * Math.sin(lonRad) + dy;
const z = primeVerticalRadius * (1 - sourceE2) * sinLat + dz;
const lonBessel = Math.atan2(y, x);
const horizontal = Math.sqrt(x * x + y * y);
let latBessel = Math.atan2(z, horizontal * (1 - targetE2));
for (let index = 0; index < 8; index += 1) {
const sinLatBessel = Math.sin(latBessel);
const besselRadius = BESSEL_A / Math.sqrt(1 - targetE2 * sinLatBessel * sinLatBessel);
const nextLat = Math.atan2(z + targetE2 * besselRadius * sinLatBessel, horizontal);
if (Math.abs(nextLat - latBessel) < 1e-14) {
latBessel = nextLat;
break;
}
latBessel = nextLat;
}
return [latBessel, lonBessel];
}
function wgs84ToAirKoreaTm(latitude, longitude) {
const [latRad, lonRad] = wgs84ToBessel(latitude, longitude);
const besselE2 = 2 * BESSEL_F - BESSEL_F ** 2;
const secondEccentricitySquared = besselE2 / (1 - besselE2);
const sinLat = Math.sin(latRad);
const cosLat = Math.cos(latRad);
const tanLat = Math.tan(latRad);
const primeVerticalRadius = BESSEL_A / Math.sqrt(1 - besselE2 * sinLat * sinLat);
const tanSquared = tanLat * tanLat;
const curvature = secondEccentricitySquared * cosLat * cosLat;
const A = (lonRad - AIR_KOREA_TM_LON0) * cosLat;
const meridional = meridionalArc(latRad, {
semiMajorAxis: BESSEL_A,
eccentricitySquared: besselE2
});
const meridionalOrigin = meridionalArc(AIR_KOREA_TM_LAT0, {
semiMajorAxis: BESSEL_A,
eccentricitySquared: besselE2
});
const tmX = AIR_KOREA_TM_FALSE_EASTING + AIR_KOREA_TM_SCALE * primeVerticalRadius * (
A +
((1 - tanSquared + curvature) * A ** 3) / 6 +
((5 - 18 * tanSquared + tanSquared ** 2 + 72 * curvature - 58 * secondEccentricitySquared) * A ** 5) / 120
);
const tmY = AIR_KOREA_TM_FALSE_NORTHING + AIR_KOREA_TM_SCALE * (
meridional -
meridionalOrigin +
primeVerticalRadius * tanLat * (
A ** 2 / 2 +
((5 - tanSquared + 9 * curvature + 4 * curvature ** 2) * A ** 4) / 24 +
((61 - 58 * tanSquared + tanSquared ** 2 + 600 * curvature - 330 * secondEccentricitySquared) * A ** 6) / 720
)
);
return { tmX, tmY };
}
function pickStation(stationItems, { lat = null, lon = null, regionHint = null, stationName = null } = {}) {
if (!stationItems.length) {
throw new Error("측정소 후보가 없습니다.");
}
if (stationName) {
const exactMatch = stationItems.find((item) => item.stationName === stationName);
if (exactMatch) {
return exactMatch;
}
const partialMatch = stationItems.find((item) =>
String(item.stationName || "").includes(stationName) || String(item.addr || "").includes(stationName)
);
if (partialMatch) {
return partialMatch;
}
}
if (Number.isFinite(lat) && Number.isFinite(lon)) {
const candidates = stationItems
.map((item) => {
const itemLat = toFloat(item.dmX);
const itemLon = toFloat(item.dmY);
if (itemLat === null || itemLon === null) {
return null;
}
return [squaredDistance(lat, lon, itemLat, itemLon), item];
})
.filter(Boolean)
.sort((left, right) => left[0] - right[0]);
if (candidates.length > 0) {
return candidates[0][1];
}
}
if (regionHint) {
const tokens = [...new Set(String(regionHint).split(/\s+/u).filter(Boolean))].sort((left, right) => right.length - left.length);
for (const token of tokens) {
const stationNameMatch = stationItems.find((item) => String(item.stationName || "").includes(token));
if (stationNameMatch) {
return stationNameMatch;
}
const addressMatch = stationItems.find((item) => String(item.addr || "").includes(token));
if (addressMatch) {
return addressMatch;
}
}
}
return stationItems[0];
}
function resolveStation(stationItems, options = {}) {
if (stationItems.length > 0) {
return pickStation(stationItems, options);
}
if (options.stationName) {
return {
stationName: options.stationName,
addr: null
};
}
throw new Error("측정소 후보가 없습니다.");
}
function buildStationNameCandidates({ stationName = null, regionHint = null } = {}) {
const candidates = [];
if (stationName) {
candidates.push(String(stationName).trim());
}
if (regionHint) {
const tokens = [...new Set(
String(regionHint)
.split(/\s+/u)
.map((token) => token.trim())
.filter(Boolean)
.sort((left, right) => right.length - left.length)
)];
candidates.push(...tokens);
}
return [...new Set(candidates.filter(Boolean))];
}
function findMeasurement(measurementItems, stationName) {
const exactMatch = measurementItems.find((item) => item.stationName === stationName);
if (exactMatch) {
return exactMatch;
}
const partialMatch = measurementItems.find((item) => String(item.stationName || "").includes(stationName));
if (partialMatch) {
return partialMatch;
}
throw new Error(`측정값 응답에서 측정소 '${stationName}' 를 찾지 못했습니다.`);
}
function gradeToLabel(rawGrade, { pollutant, value }) {
const rawText = rawGrade === null || rawGrade === undefined ? "" : String(rawGrade);
if (Object.prototype.hasOwnProperty.call(GRADE_LABELS, rawText)) {
return GRADE_LABELS[rawText];
}
const numericValue = toFloat(value);
if (numericValue === null) {
return "정보없음";
}
const thresholds = pollutant === "pm10"
? [[30, "좋음"], [80, "보통"], [150, "나쁨"]]
: [[15, "좋음"], [35, "보통"], [75, "나쁨"]];
for (const [threshold, label] of thresholds) {
if (numericValue <= threshold) {
return label;
}
}
return "매우나쁨";
}
function buildReport({ stationItems, measurementItems, lat = null, lon = null, regionHint = null, stationName = null, lookupMode = null, selectedStation = null }) {
const station = selectedStation || resolveStation(stationItems, {
lat,
lon,
regionHint,
stationName
});
const measurement = findMeasurement(measurementItems, station.stationName);
const resolvedLookupMode = lookupMode || (Number.isFinite(lat) && Number.isFinite(lon) ? "coordinates" : "fallback");
return {
station_name: station.stationName,
station_address: station.addr ?? null,
lookup_mode: resolvedLookupMode,
measured_at: measurement.dataTime ?? null,
pm10: {
value: String(measurement.pm10Value ?? "-"),
grade: gradeToLabel(measurement.pm10Grade, {
pollutant: "pm10",
value: measurement.pm10Value
})
},
pm25: {
value: String(measurement.pm25Value ?? "-"),
grade: gradeToLabel(measurement.pm25Grade, {
pollutant: "pm25",
value: measurement.pm25Value
})
},
khai_grade: measurement.khaiGrade === null || measurement.khaiGrade === undefined || measurement.khaiGrade === ""
? "정보없음"
: gradeToLabel(measurement.khaiGrade, {
pollutant: "pm10",
value: measurement.pm10Value
})
};
}
async function fetchJson(baseUrl, params, { fetchImpl = global.fetch, headers = {} } = {}) {
if (typeof fetchImpl !== "function") {
throw new Error("A fetch implementation is required.");
}
const url = new URL(baseUrl);
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== null && value !== undefined && value !== "") {
searchParams.set(key, String(value));
}
}
url.search = searchParams.toString();
const response = await fetchImpl(url, {
headers,
signal: AbortSignal.timeout(20000)
});
if (!response.ok) {
const body = await response.text().catch(() => "");
if (response.status === 403) {
throw new Error(
"AirKorea upstream returned 403 Forbidden. 기술문서 기준 후보 원인: 활용신청 후 동기화 대기(1~2시간), 활용신청하지 않은 API 호출, 서비스키 인코딩/서비스키 오류, 등록하지 않은 도메인 또는 IP.",
);
}
throw new Error(`AirKorea request failed with ${response.status} for ${url}${body ? ` :: ${body.slice(0, 200)}` : ""}`);
}
return JSON.parse(await response.text());
}
async function fetchStationLookup({ lat = null, lon = null, regionHint = null, stationName = null, serviceKey, fetchImpl = global.fetch, headers = {}, stationServiceUrl = STATION_SERVICE_URL }) {
if (!serviceKey) {
throw new Error("AIR_KOREA_OPEN_API_KEY is not configured on the proxy server.");
}
const common = {
serviceKey,
returnType: "json",
numOfRows: 50,
pageNo: 1
};
if (Number.isFinite(lat) && Number.isFinite(lon)) {
const { tmX, tmY } = wgs84ToAirKoreaTm(lat, lon);
const nearbyPayload = await fetchJson(`${stationServiceUrl}/getNearbyMsrstnList`, {
...common,
numOfRows: 10,
tmX,
tmY
}, {
fetchImpl,
headers
});
if (extractItems(nearbyPayload).length > 0) {
return {
lookupMode: "coordinates",
payload: nearbyPayload
};
}
}
if (regionHint || stationName) {
return {
lookupMode: "fallback",
payload: await fetchJson(`${stationServiceUrl}/getMsrstnList`, {
...common,
addr: regionHint,
stationName
}, {
fetchImpl,
headers
})
};
}
throw new Error("위도/경도 또는 region fallback 이 필요합니다.");
}
async function fetchMeasurementPayload({ stationName, serviceKey, fetchImpl = global.fetch, headers = {}, measurementServiceUrl = MEASUREMENT_SERVICE_URL }) {
if (!serviceKey) {
throw new Error("AIR_KOREA_OPEN_API_KEY is not configured on the proxy server.");
}
return fetchJson(`${measurementServiceUrl}/getMsrstnAcctoRltmMesureDnsty`, {
serviceKey,
returnType: "json",
numOfRows: 100,
pageNo: 1,
stationName,
dataTerm: "DAILY",
ver: "1.4"
}, {
fetchImpl,
headers
});
}
async function fetchFineDustReport({ lat = null, lon = null, regionHint = null, stationName = null, serviceKey, fetchImpl = global.fetch, headers = {}, stationServiceUrl = STATION_SERVICE_URL, measurementServiceUrl = MEASUREMENT_SERVICE_URL }) {
let stationLookup;
let stationItems;
let station;
try {
stationLookup = await fetchStationLookup({
lat,
lon,
regionHint,
stationName,
serviceKey,
fetchImpl,
headers,
stationServiceUrl
});
stationItems = extractItems(stationLookup.payload);
station = resolveStation(stationItems, {
lat,
lon,
regionHint,
stationName
});
} catch (error) {
const candidates = buildStationNameCandidates({ stationName, regionHint });
const canTryMeasurementOnlyFallback =
String(error?.message || "").includes("403 Forbidden") &&
candidates.length > 0;
if (!canTryMeasurementOnlyFallback) {
throw error;
}
for (const candidate of candidates) {
const measurementPayload = await fetchMeasurementPayload({
stationName: candidate,
serviceKey,
fetchImpl,
headers,
measurementServiceUrl
});
const measurementItems = extractItems(measurementPayload);
try {
const matchedMeasurement = findMeasurement(measurementItems, candidate);
return buildReport({
stationItems: [{ stationName: matchedMeasurement.stationName, addr: null }],
measurementItems,
lat,
lon,
regionHint,
stationName: matchedMeasurement.stationName,
lookupMode: "fallback",
selectedStation: { stationName: matchedMeasurement.stationName, addr: null }
});
} catch {
// try next candidate
}
}
throw error;
}
const measurementPayload = await fetchMeasurementPayload({
stationName: station.stationName,
serviceKey,
fetchImpl,
headers,
measurementServiceUrl
});
return buildReport({
stationItems,
measurementItems: extractItems(measurementPayload),
lat,
lon,
regionHint,
stationName: station.stationName,
lookupMode: stationLookup.lookupMode,
selectedStation: station
});
}
module.exports = {
GRADE_LABELS,
STATION_SERVICE_URL,
MEASUREMENT_SERVICE_URL,
buildReport,
extractItems,
fetchFineDustReport,
fetchMeasurementPayload,
fetchStationLookup,
findMeasurement,
gradeToLabel,
pickStation,
resolveStation,
toFloat,
wgs84ToAirKoreaTm
};

View file

@ -0,0 +1,331 @@
const crypto = require("node:crypto");
const Fastify = require("fastify");
const { fetchFineDustReport } = require("./airkorea");
const UPSTREAM_BASE_URL = "http://apis.data.go.kr";
const ALLOWED_AIRKOREA_ROUTES = new Map([
["MsrstnInfoInqireSvc", new Set(["getMsrstnList", "getNearbyMsrstnList", "getTMStdrCrdnt"])],
["ArpltnInforInqireSvc", new Set(["getMsrstnAcctoRltmMesureDnsty"])],
["UserSportSvc", new Set(["getSvckeyDalyStats"])],
]);
function parseInteger(value, fallback) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function parseFloatValue(value) {
if (value === undefined || value === null || value === "") {
return null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : Number.NaN;
}
function trimOrNull(value) {
if (value === undefined || value === null) {
return null;
}
const trimmed = String(value).trim();
if (!trimmed || trimmed === "replace-me") {
return null;
}
return trimmed;
}
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),
cacheTtlMs: parseInteger(env.KSKILL_PROXY_CACHE_TTL_MS, 300000),
rateLimitWindowMs: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_WINDOW_MS, 60000),
rateLimitMax: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_MAX, 60)
};
}
function makeCacheKey(payload) {
return crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex");
}
function createMemoryCache() {
const entries = new Map();
return {
get(key) {
const cached = entries.get(key);
if (!cached) {
return null;
}
if (cached.expiresAt <= Date.now()) {
entries.delete(key);
return null;
}
return cached.value;
},
set(key, value, ttlMs) {
entries.set(key, {
value,
expiresAt: Date.now() + ttlMs
});
}
};
}
function buildRateLimiter(config) {
const state = new Map();
return function rateLimit(request, reply) {
const key = trimOrNull(request.headers["cf-connecting-ip"]) || request.ip || "unknown";
const now = Date.now();
const current = state.get(key);
if (!current || current.resetAt <= now) {
state.set(key, {
count: 1,
resetAt: now + config.rateLimitWindowMs
});
return true;
}
if (current.count >= config.rateLimitMax) {
reply.code(429).send({
error: "rate_limited",
message: "Too many requests.",
retry_after_ms: current.resetAt - now
});
return false;
}
current.count += 1;
return true;
};
}
function normalizeFineDustQuery(query) {
const lat = parseFloatValue(query.lat);
const lon = parseFloatValue(query.lon);
const regionHint = trimOrNull(query.regionHint ?? query.region_hint);
const stationName = trimOrNull(query.stationName ?? query.station_name);
if ((lat !== null && Number.isNaN(lat)) || (lon !== null && Number.isNaN(lon))) {
throw new Error("lat/lon must be finite numbers.");
}
if ((lat === null) !== (lon === null)) {
throw new Error("lat and lon must be provided together.");
}
if (lat === null && !regionHint && !stationName) {
throw new Error("Provide lat/lon, regionHint, or stationName.");
}
return {
lat,
lon,
regionHint,
stationName
};
}
function isAllowedAirKoreaRoute(service, operation) {
return ALLOWED_AIRKOREA_ROUTES.get(service)?.has(operation) || false;
}
async function proxyAirKoreaRequest({ service, operation, query, serviceKey, fetchImpl = global.fetch }) {
if (!serviceKey) {
return {
statusCode: 503,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "upstream_not_configured",
message: "AIR_KOREA_OPEN_API_KEY is not configured on the proxy server."
})
};
}
if (!isAllowedAirKoreaRoute(service, operation)) {
return {
statusCode: 404,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "not_found",
message: "That AirKorea route is not exposed by this proxy."
})
};
}
const url = new URL(`${UPSTREAM_BASE_URL}/B552584/${service}/${operation}`);
for (const [key, value] of Object.entries(query || {})) {
if (value === undefined || value === null || value === "" || key === "serviceKey") {
continue;
}
if (Array.isArray(value)) {
for (const item of value) {
url.searchParams.append(key, String(item));
}
continue;
}
url.searchParams.set(key, String(value));
}
url.searchParams.set("serviceKey", serviceKey);
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()
};
}
function buildServer({ env = process.env, provider = null } = {}) {
const config = buildConfig(env);
const cache = createMemoryCache();
const rateLimit = buildRateLimiter(config);
const app = Fastify({
logger: true,
disableRequestLogging: true
});
app.decorate("configValues", config);
app.decorate("provider", provider || ((params) => fetchFineDustReport({
...params,
serviceKey: config.airKoreaApiKey
})));
app.addHook("onRequest", async (request, reply) => {
if (request.url === "/health") {
return;
}
if (!rateLimit(request, reply)) {
return reply;
}
});
app.get("/health", async () => ({
ok: true,
service: config.proxyName,
port: config.port,
upstreams: {
airKoreaConfigured: Boolean(config.airKoreaApiKey)
},
auth: {
tokenRequired: false
},
timestamp: new Date().toISOString()
}));
app.get("/B552584/:service/:operation", async (request, reply) => {
const { service, operation } = request.params;
const upstream = await proxyAirKoreaRequest({
service,
operation,
query: request.query,
serviceKey: config.airKoreaApiKey
});
reply.code(upstream.statusCode);
reply.header("content-type", upstream.contentType);
return upstream.body;
});
app.get("/v1/fine-dust/report", async (request, reply) => {
let normalized;
try {
normalized = normalizeFineDustQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey(normalized);
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
if (!config.airKoreaApiKey) {
reply.code(503);
return {
error: "upstream_not_configured",
message: "AIR_KOREA_OPEN_API_KEY is not configured on the proxy server.",
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
}
const report = await app.provider(normalized);
const payload = {
...report,
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
}
};
cache.set(cacheKey, payload, config.cacheTtlMs);
return payload;
});
app.setErrorHandler((error, request, reply) => {
request.log.error(error);
const statusCode = error.statusCode && error.statusCode >= 400 ? error.statusCode : 500;
reply.code(statusCode).send({
error: statusCode >= 500 ? "proxy_error" : "request_error",
message: error.message
});
});
return app;
}
async function startServer() {
const app = buildServer();
const { host, port } = app.configValues;
await app.listen({ host, port });
return app;
}
if (require.main === module) {
startServer().catch((error) => {
console.error(error);
process.exitCode = 1;
});
}
module.exports = {
buildConfig,
buildServer,
normalizeFineDustQuery,
proxyAirKoreaRequest,
startServer
};

View file

@ -0,0 +1,163 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
buildReport,
fetchFineDustReport,
pickStation,
wgs84ToAirKoreaTm
} = require("../src/airkorea");
const stationPayload = {
response: {
body: {
items: [
{
stationName: "강남구",
addr: "서울 강남구 학동로 426",
dmX: 37.5179,
dmY: 127.0473
},
{
stationName: "중구",
addr: "서울 중구 서소문로 124",
dmX: 37.564,
dmY: 126.975
}
]
}
}
};
const measurementPayload = {
response: {
body: {
items: [
{
stationName: "강남구",
dataTime: "2026-03-27 21:00",
pm10Value: "42",
pm10Grade: "2",
pm25Value: "19",
pm25Grade: "2",
khaiGrade: "2"
}
]
}
}
};
test("wgs84 coordinates are converted to AirKorea TM", () => {
const { tmX, tmY } = wgs84ToAirKoreaTm(37.5665, 126.9780);
assert.ok(Math.abs(tmX - 198245.053) < 0.01);
assert.ok(Math.abs(tmY - 451586.838) < 0.01);
});
test("pickStation prefers specific region token matches", () => {
const station = pickStation(stationPayload.response.body.items, {
regionHint: "서울 강남구"
});
assert.equal(station.stationName, "강남구");
});
test("buildReport combines station and measurement summary", () => {
const report = buildReport({
stationItems: stationPayload.response.body.items,
measurementItems: measurementPayload.response.body.items,
regionHint: "서울 강남구"
});
assert.equal(report.station_name, "강남구");
assert.deepEqual(report.pm10, { value: "42", grade: "보통" });
assert.deepEqual(report.pm25, { value: "19", grade: "보통" });
assert.equal(report.lookup_mode, "fallback");
});
test("fetchFineDustReport falls back to region lookup when nearby returns empty", async () => {
const calls = [];
const fetchImpl = async (url) => {
calls.push(String(url));
if (String(url).includes("getNearbyMsrstnList")) {
return new Response(JSON.stringify({ response: { body: { items: [] } } }), {
status: 200,
headers: { "content-type": "application/json" }
});
}
if (String(url).includes("getMsrstnList")) {
return new Response(JSON.stringify({
response: {
body: {
items: [stationPayload.response.body.items[0]]
}
}
}), {
status: 200,
headers: { "content-type": "application/json" }
});
}
if (String(url).includes("getMsrstnAcctoRltmMesureDnsty")) {
return new Response(JSON.stringify(measurementPayload), {
status: 200,
headers: { "content-type": "application/json" }
});
}
throw new Error(`unexpected URL: ${url}`);
};
const report = await fetchFineDustReport({
lat: 37.5665,
lon: 126.978,
regionHint: "서울 강남구",
serviceKey: "test-key",
fetchImpl
});
assert.equal(report.station_name, "강남구");
assert.equal(report.lookup_mode, "fallback");
assert.deepEqual(calls.map((url) => url.split("/").at(-1)?.split("?")[0]), [
"getNearbyMsrstnList",
"getMsrstnList",
"getMsrstnAcctoRltmMesureDnsty"
]);
});
test("fetchFineDustReport falls back to direct measurement lookup when station-info access is forbidden", async () => {
const calls = [];
const fetchImpl = async (url) => {
const text = String(url);
calls.push(text);
if (text.includes("getMsrstnList")) {
return new Response("Forbidden", { status: 403, headers: { "content-type": "text/plain" } });
}
if (text.includes("getMsrstnAcctoRltmMesureDnsty")) {
return new Response(JSON.stringify(measurementPayload), {
status: 200,
headers: { "content-type": "application/json" }
});
}
throw new Error(`unexpected URL: ${url}`);
};
const report = await fetchFineDustReport({
regionHint: "서울 강남구",
serviceKey: "test-key",
fetchImpl
});
assert.equal(report.station_name, "강남구");
assert.equal(report.station_address, null);
assert.equal(report.lookup_mode, "fallback");
assert.deepEqual(calls.map((url) => url.split("/").at(-1)?.split("?")[0]), [
"getMsrstnList",
"getMsrstnAcctoRltmMesureDnsty"
]);
});

View file

@ -0,0 +1,148 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { buildServer, proxyAirKoreaRequest } = require("../src/server");
test("health endpoint stays public and reports auth/upstream status", async (t) => {
const app = buildServer({
provider: async () => {
throw new Error("provider should not be called");
}
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/health"
});
assert.equal(response.statusCode, 200);
const body = response.json();
assert.equal(body.ok, true);
assert.equal(body.auth.tokenRequired, false);
assert.equal(body.upstreams.airKoreaConfigured, false);
});
test("fine dust endpoint stays publicly callable without proxy auth", async (t) => {
let providerCalls = 0;
const app = buildServer({
env: {
AIR_KOREA_OPEN_API_KEY: "airkorea-key"
},
provider: async () => {
providerCalls += 1;
return { station_name: "강남구" };
}
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/fine-dust/report?regionHint=%EC%84%9C%EC%9A%B8%20%EA%B0%95%EB%82%A8%EA%B5%AC"
});
assert.equal(response.statusCode, 200);
assert.equal(response.json().station_name, "강남구");
assert.equal(providerCalls, 1);
});
test("fine dust endpoint caches successful provider responses", async (t) => {
let providerCalls = 0;
const app = buildServer({
env: {
AIR_KOREA_OPEN_API_KEY: "airkorea-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
},
provider: async () => {
providerCalls += 1;
return {
station_name: "강남구",
station_address: "서울 강남구 학동로 426",
lookup_mode: "fallback",
measured_at: "2026-03-27 21:00",
pm10: { value: "42", grade: "보통" },
pm25: { value: "19", grade: "보통" },
khai_grade: "보통"
};
}
});
t.after(async () => {
await app.close();
});
const request = {
method: "GET",
url: "/v1/fine-dust/report?regionHint=%EC%84%9C%EC%9A%B8%20%EA%B0%95%EB%82%A8%EA%B5%AC"
};
const first = await app.inject(request);
const second = await app.inject(request);
assert.equal(first.statusCode, 200);
assert.equal(second.statusCode, 200);
assert.equal(providerCalls, 1);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
});
test("proxyAirKoreaRequest injects serviceKey and preserves caller query params", async () => {
let calledUrl;
const result = await proxyAirKoreaRequest({
service: "ArpltnInforInqireSvc",
operation: "getMsrstnAcctoRltmMesureDnsty",
query: {
returnType: "json",
stationName: "강남구",
dataTerm: "DAILY",
ver: "1.4"
},
serviceKey: "test-service-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.match(calledUrl, /\/B552584\/ArpltnInforInqireSvc\/getMsrstnAcctoRltmMesureDnsty\?/);
assert.match(calledUrl, /stationName=%EA%B0%95%EB%82%A8%EA%B5%AC/);
assert.match(calledUrl, /serviceKey=test-service-key/);
});
test("public AirKorea passthrough route forwards allowed upstream responses", async (t) => {
const originalFetch = global.fetch;
global.fetch = async () =>
new Response('{"response":{"header":{"resultCode":"00"}}}', {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
const app = buildServer({
env: {
AIR_KOREA_OPEN_API_KEY: "airkorea-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/B552584/ArpltnInforInqireSvc/getMsrstnAcctoRltmMesureDnsty?returnType=json&stationName=%EA%B0%95%EB%82%A8%EA%B5%AC&dataTerm=DAILY&ver=1.4"
});
assert.equal(response.statusCode, 200);
assert.match(response.body, /resultCode/);
});

View file

@ -6,6 +6,7 @@ import json
import os
import pathlib
import sys
import urllib.error
import urllib.parse
import urllib.request
from math import atan2, cos, radians, sin, sqrt, tan
@ -13,6 +14,8 @@ from math import atan2, cos, radians, sin, sqrt, tan
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"
PROXY_BASE_URL_NAME = "KSKILL_PROXY_BASE_URL"
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
WGS84_A = 6378137.0
WGS84_F = 1 / 298.257223563
BESSEL_A = 6377397.155
@ -343,16 +346,59 @@ def build_missing_secret_message() -> str:
def get_required_secret() -> str:
value = os.environ.get(SECRET_NAME)
if not value:
if not value or value == "replace-me":
raise SystemExit(build_missing_secret_message())
return value
def get_proxy_base_url() -> str | None:
value = os.environ.get(PROXY_BASE_URL_NAME)
if value and value.lower() in {"off", "false", "0", "disable", "disabled", "none"}:
return None
if value and value != "replace-me":
return value.rstrip("/")
return DEFAULT_PROXY_BASE_URL
def read_json_response(request: urllib.request.Request | str) -> dict:
try:
with urllib.request.urlopen(request, timeout=20) as response:
return json.load(response)
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
try:
payload = json.loads(body)
except json.JSONDecodeError:
payload = None
message = payload.get("message") if isinstance(payload, dict) else None
raise SystemExit(message or f"요청이 실패했습니다: HTTP {exc.code}") from exc
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)
return read_json_response(request_url)
def fetch_proxy_report(args: argparse.Namespace) -> dict | None:
base_url = get_proxy_base_url()
if not base_url or args.station_file or args.measurement_file:
return None
params: dict[str, object] = {}
if args.lat is not None:
params["lat"] = args.lat
if args.lon is not None:
params["lon"] = args.lon
if args.region_hint:
params["regionHint"] = args.region_hint
if args.station_name:
params["stationName"] = args.station_name
query = urllib.parse.urlencode(params)
request = urllib.request.Request(f"{base_url}/v1/fine-dust/report?{query}")
return read_json_response(request)
def fetch_station_lookup(args: argparse.Namespace) -> tuple[dict, str]:
@ -436,6 +482,15 @@ def render_text(report: dict) -> str:
def command_report(args: argparse.Namespace) -> None:
proxy_report = fetch_proxy_report(args)
if proxy_report is not None:
if args.json:
print(json.dumps(proxy_report, ensure_ascii=False, indent=2))
return
print(render_text(proxy_report))
return
station_payload, lookup_mode = fetch_station_lookup(args)
station_items = extract_items(station_payload)
station = resolve_station(

25
scripts/run-k-skill-proxy.sh Executable file
View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SECRETS_FILE="${KSKILL_SECRETS_FILE:-$HOME/.config/k-skill/secrets.env}"
AGE_KEY_FILE="${SOPS_AGE_KEY_FILE:-$HOME/.config/k-skill/age/keys.txt}"
if ! command -v sops >/dev/null 2>&1; then
echo "missing command: sops" >&2
exit 1
fi
if [[ ! -f "$SECRETS_FILE" ]]; then
echo "missing encrypted secrets file: $SECRETS_FILE" >&2
exit 1
fi
if [[ ! -f "$AGE_KEY_FILE" ]]; then
echo "missing age key file: $AGE_KEY_FILE" >&2
exit 1
fi
cd "$ROOT_DIR"
exec env SOPS_AGE_KEY_FILE="$AGE_KEY_FILE" \
sops exec-env "$SECRETS_FILE" 'node packages/k-skill-proxy/src/server.js'

View file

@ -637,8 +637,17 @@ test("fine-dust-location skill documents the official two-api flow and fallback
assert.match(skill, /^name: fine-dust-location$/m);
assert.match(skill, /^description: .*미세먼지.*초미세먼지.*위치.*$/m);
assert.match(skill, /k-skill-proxy\.nomadamas\.org\/v1\/fine-dust\/report/);
assert.match(skill, /행정구역 이름/u);
assert.match(skill, /강남구/);
assert.match(skill, /python3 scripts\/fine_dust\.py/);
assert.match(skill, /docs\/features\/fine-dust-location\.md/);
assert.match(skill, /docs\/features\/k-skill-proxy\.md/);
assert.match(skill, /PM10/);
assert.match(skill, /PM2\.5|PM25/);
assert.match(skill, /통합대기등급/);
for (const doc of [skill, featureDoc]) {
for (const doc of [featureDoc]) {
assert.match(doc, /AIR_KOREA_OPEN_API_KEY/);
assert.match(doc, /B552584\/MsrstnInfoInqireSvc\/getNearbyMsrstnList/);
assert.match(doc, /B552584\/MsrstnInfoInqireSvc\/getMsrstnList/);

View file

@ -190,6 +190,7 @@ class FineDustTests(unittest.TestCase):
with (
redirect_stdout(stdout),
mock.patch.dict(fine_dust.os.environ, {"KSKILL_PROXY_BASE_URL": "off"}, clear=False),
mock.patch.object(fine_dust, "get_required_secret", return_value="test-secret"),
mock.patch.object(fine_dust, "fetch_json", side_effect=fake_fetch_json),
):
@ -240,6 +241,7 @@ class FineDustTests(unittest.TestCase):
with (
redirect_stdout(stdout),
mock.patch.dict(fine_dust.os.environ, {"KSKILL_PROXY_BASE_URL": "off"}, clear=False),
mock.patch.object(fine_dust, "get_required_secret", return_value="test-secret"),
mock.patch.object(fine_dust, "fetch_json", side_effect=fake_fetch_json),
):
@ -252,6 +254,31 @@ class FineDustTests(unittest.TestCase):
self.assertEqual([url.rsplit("/", 1)[-1] for url, _ in recorded_calls], ["getMsrstnList", "getMsrstnAcctoRltmMesureDnsty"])
self.assertEqual(recorded_calls[1][1]["stationName"], "중구")
def test_cli_json_report_prefers_proxy_when_proxy_base_url_is_configured(self):
stdout = io.StringIO()
proxy_report = {
"station_name": "강남구",
"station_address": "서울 강남구 학동로 426",
"lookup_mode": "fallback",
"measured_at": "2026-03-27 21:00",
"pm10": {"value": "42", "grade": "보통"},
"pm25": {"value": "19", "grade": "보통"},
"khai_grade": "보통",
"proxy": {"name": "k-skill-proxy"},
}
with (
redirect_stdout(stdout),
mock.patch.dict(fine_dust.os.environ, {"KSKILL_PROXY_BASE_URL": "https://k-skill-proxy.nomadamas.org"}),
mock.patch.object(fine_dust, "fetch_proxy_report", return_value=proxy_report),
mock.patch.object(fine_dust, "fetch_station_lookup", side_effect=AssertionError("direct lookup should not run")),
):
fine_dust.main(["report", "--region-hint", "서울 강남구", "--json"])
rendered = json.loads(stdout.getvalue())
self.assertEqual(rendered["station_name"], "강남구")
self.assertEqual(rendered["proxy"]["name"], "k-skill-proxy")
if __name__ == "__main__":
unittest.main()