Compare commits

...

5 commits

Author SHA1 Message Date
Jeffrey (Dongkyu) Kim
f57eb3e5d0 Merge origin/dev into feature/#274 — keep stricter Cloud Run/WIF assertions in skill-docs test 2026-05-22 11:43:07 +09:00
Jeffrey (Dongkyu) Kim
3ded0a049c Protect hosted Seoul Bike proxy secrets
Sanitize Seoul Bike upstream fetch and parse failures before they can reach the global error handler, and reject blank nearby coordinates before JavaScript can coerce them to zero.\n\nConstraint: PR #277 round-3 review found server-side Seoul Open API keys could leak through exception messages containing keyed upstream URLs.\nRejected: Letting the global error handler format Seoul Bike upstream exceptions | it echoes exception messages and can expose the hosted proxy API key.\nConfidence: high\nScope-risk: narrow\nDirective: Keep server-side API-key-bearing upstream URLs out of client-visible error messages and logs for hosted no-user-key routes.\nTested: node --test packages/k-skill-proxy/test/server.test.js --test-name-pattern 'seoul bike'; PYTHONPATH=.:scripts python3 -m unittest scripts.test_seoul_bike; explicit app.inject smoke for sanitized Seoul Bike failures and blank coordinates; local fake-proxy seoul-bike nearby smoke; PATH="/Users/jeffrey/.pyenv/versions/3.11.9/bin:/Users/jeffrey/.codex/tmp/arg0/codex-arg0mxZmWx:/opt/homebrew/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/path:/Users/jeffrey/.cmuxterm/omo-bin:/opt/homebrew/share/android-commandlinetools/platform-tools:/opt/homebrew/share/android-commandlinetools/emulator:/opt/homebrew/share/android-commandlinetools/cmdline-tools/latest/bin:/Users/jeffrey/.local/bin:/Users/jeffrey/.bun/bin:/opt/homebrew/opt/node@22/bin:/opt/homebrew/opt/openjdk@21/bin:/opt/homebrew/opt/postgresql@18/bin:/Users/jeffrey/.jenv/shims:/Users/jeffrey/.jenv/bin:/opt/homebrew/opt/imagemagick/bin:/opt/homebrew/Cellar/pyenv-virtualenv/1.4.0/shims:/Users/jeffrey/.pyenv/shims:/opt/homebrew/opt/openssl@3/bin:/Users/jeffrey/.rbenv/shims:/Users/jeffrey/.rbenv/bin:/Users/jeffrey/google-cloud-sdk/bin:/Applications/cmux.app/Contents/Resources/bin:/Users/jeffrey/Library/pnpm:/Users/jeffrey/.nvm/versions/node/v24.13.0/bin:/Users/jeffrey/.cops/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/opt/pmk/env/global/bin:/Library/Apple/usr/bin:/Library/TeX/texbin:/Users/jeffrey/.cargo/bin:/Users/jeffrey/Library/Application Support/JetBrains/Toolbox/scripts:/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/bin:/Users/jeffrey/xcode-projects/marshroom/cli" npm run ci.\nNot-tested: Live Seoul Open API network failure from production Cloud Run.
2026-05-21 16:03:08 +09:00
Jeffrey (Dongkyu) Kim
3965cd7396 Reject ambiguous Seoul Bike integer input
Tighten the public Seoul Bike query boundary so malformed integer strings cannot be partially parsed into valid requests.

Constraint: PR #277 review found parseInt accepted partially numeric query values on Seoul Bike routes.\nRejected: Keep parseInt with bounds checks | bounds still allow misleading values like 10abc and 1.5.\nConfidence: high\nScope-risk: narrow\nDirective: Keep Seoul Bike public query aliases strict; do not reintroduce partial numeric parsing.\nTested: node --test packages/k-skill-proxy/test/server.test.js --test-name-pattern 'seoul bike'; PYTHONPATH=.:scripts python3 -m unittest scripts.test_seoul_bike; explicit app.inject invalid-query smoke; PATH="/Users/jeffrey/.pyenv/versions/3.11.9/bin:/Users/jeffrey/.codex/tmp/arg0/codex-arg0uv50Mt:/opt/homebrew/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/path:/Users/jeffrey/.cmuxterm/omo-bin:/opt/homebrew/share/android-commandlinetools/platform-tools:/opt/homebrew/share/android-commandlinetools/emulator:/opt/homebrew/share/android-commandlinetools/cmdline-tools/latest/bin:/Users/jeffrey/.local/bin:/Users/jeffrey/.bun/bin:/opt/homebrew/opt/node@22/bin:/opt/homebrew/opt/openjdk@21/bin:/opt/homebrew/opt/postgresql@18/bin:/Users/jeffrey/.jenv/shims:/Users/jeffrey/.jenv/bin:/opt/homebrew/opt/imagemagick/bin:/opt/homebrew/Cellar/pyenv-virtualenv/1.4.0/shims:/Users/jeffrey/.pyenv/shims:/opt/homebrew/opt/openssl@3/bin:/Users/jeffrey/.rbenv/shims:/Users/jeffrey/.rbenv/bin:/Users/jeffrey/google-cloud-sdk/bin:/Applications/cmux.app/Contents/Resources/bin:/Users/jeffrey/Library/pnpm:/Users/jeffrey/.nvm/versions/node/v24.13.0/bin:/Users/jeffrey/.cops/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/opt/pmk/env/global/bin:/Library/Apple/usr/bin:/Library/TeX/texbin:/Users/jeffrey/.cargo/bin:/Users/jeffrey/Library/Application Support/JetBrains/Toolbox/scripts:/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/bin:/Users/jeffrey/xcode-projects/marshroom/cli" npm run ci\nNot-tested: live hosted Seoul Open API traffic
2026-05-21 15:49:35 +09:00
Jeffrey (Dongkyu) Kim
b4a15406cf Prevent Seoul Bike upstream errors from masquerading as empty availability
Constraint: Seoul Open API can return application-level error JSON with HTTP 200, so proxy routes must inspect RESULT envelopes before caching or normalizing rows.
Rejected: Treating missing rentBikeStatus.row as an empty success | it masks quota/service failures and caches false no-station results.
Confidence: high
Scope-risk: narrow
Directive: Preserve non-cacheable proxy error behavior for Seoul Open API semantic failures across realtime, stations, and nearby routes.
Tested: node --test packages/k-skill-proxy/test/server.test.js --test-name-pattern 'seoul bike'; PYTHONPATH=.:scripts python3 -m unittest scripts.test_seoul_bike; local fake-proxy seoul_bike.py nearby smoke; PATH="/Users/jeffrey/.pyenv/versions/3.11.9/bin:/Users/jeffrey/.codex/tmp/arg0/codex-arg0j0fIum:/opt/homebrew/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/path:/Users/jeffrey/.cmuxterm/omo-bin:/opt/homebrew/share/android-commandlinetools/platform-tools:/opt/homebrew/share/android-commandlinetools/emulator:/opt/homebrew/share/android-commandlinetools/cmdline-tools/latest/bin:/Users/jeffrey/.local/bin:/Users/jeffrey/.bun/bin:/opt/homebrew/opt/node@22/bin:/opt/homebrew/opt/openjdk@21/bin:/opt/homebrew/opt/postgresql@18/bin:/Users/jeffrey/.jenv/shims:/Users/jeffrey/.jenv/bin:/opt/homebrew/opt/imagemagick/bin:/opt/homebrew/Cellar/pyenv-virtualenv/1.4.0/shims:/Users/jeffrey/.pyenv/shims:/opt/homebrew/opt/openssl@3/bin:/Users/jeffrey/.rbenv/shims:/Users/jeffrey/.rbenv/bin:/Users/jeffrey/google-cloud-sdk/bin:/Applications/cmux.app/Contents/Resources/bin:/Users/jeffrey/Library/pnpm:/Users/jeffrey/.nvm/versions/node/v24.13.0/bin:/Users/jeffrey/.cops/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/opt/pmk/env/global/bin:/Library/Apple/usr/bin:/Library/TeX/texbin:/Users/jeffrey/.cargo/bin:/Users/jeffrey/Library/Application Support/JetBrains/Toolbox/scripts:/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/bin:/Users/jeffrey/xcode-projects/marshroom/cli" npm run ci; architect review APPROVED.
Not-tested: Live Seoul Open API error response from production service.
2026-05-21 15:39:32 +09:00
Jeffrey (Dongkyu) Kim
5e406eb32b Add Seoul Bike live station lookup
Expose narrow Seoul Open Data proxy surfaces for realtime bike availability, station master pages, and coordinate-based nearby lookups while keeping the upstream key server-side. Add a single Python skill entrypoint plus docs so agents can answer last-mile bike and dock availability questions.

Constraint: Issue #274 requires , TDD, three proxy routes, branch feature/#274, and PR to dev.
Rejected: Client-side Seoul OpenAPI key handling | would leak upstream credentials and violate existing proxy patterns.
Confidence: high
Scope-risk: moderate
Directive: Keep these routes read-only; do not add rental/booking mutations or user-key requirements.
Tested: node --test packages/k-skill-proxy/test/server.test.js --test-name-pattern 'seoul bike'; PYTHONPATH=.:scripts python3 -m unittest scripts.test_seoul_bike; local fake-proxy smoke run; PATH="/Users/jeffrey/.pyenv/versions/3.11.9/bin:/Users/jeffrey/.codex/tmp/arg0/codex-arg08RBix6:/opt/homebrew/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/path:/Users/jeffrey/.cmuxterm/omo-bin:/opt/homebrew/share/android-commandlinetools/platform-tools:/opt/homebrew/share/android-commandlinetools/emulator:/opt/homebrew/share/android-commandlinetools/cmdline-tools/latest/bin:/Users/jeffrey/.local/bin:/Users/jeffrey/.bun/bin:/opt/homebrew/opt/node@22/bin:/opt/homebrew/opt/openjdk@21/bin:/opt/homebrew/opt/postgresql@18/bin:/Users/jeffrey/.jenv/shims:/Users/jeffrey/.jenv/bin:/opt/homebrew/opt/imagemagick/bin:/opt/homebrew/Cellar/pyenv-virtualenv/1.4.0/shims:/Users/jeffrey/.pyenv/shims:/opt/homebrew/opt/openssl@3/bin:/Users/jeffrey/.rbenv/shims:/Users/jeffrey/.rbenv/bin:/Users/jeffrey/google-cloud-sdk/bin:/Applications/cmux.app/Contents/Resources/bin:/Users/jeffrey/Library/pnpm:/Users/jeffrey/.nvm/versions/node/v24.13.0/bin:/Users/jeffrey/.cops/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/opt/pmk/env/global/bin:/Library/Apple/usr/bin:/Library/TeX/texbin:/Users/jeffrey/.cargo/bin:/Users/jeffrey/Library/Application Support/JetBrains/Toolbox/scripts:/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/bin:/Users/jeffrey/xcode-projects/marshroom/cli" npm run ci.
Not-tested: Live hosted Seoul Open Data request with production SEOUL_OPEN_API_KEY.
2026-05-21 15:27:29 +09:00
17 changed files with 1463 additions and 8 deletions

View file

@ -0,0 +1,5 @@
---
"k-skill-proxy": minor
---
Add Seoul Bike realtime, station master, and nearby lookup proxy routes.

View file

@ -30,6 +30,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 카카오톡 Mac CLI | `kakaotalk-mac` | macOS에서 카카오톡 대화 조회, 검색, 메시지 전송/삭제 | 불필요(로컬 앱/권한 필요) | [카카오톡 Mac CLI 가이드](docs/features/kakaotalk-mac.md) | | 카카오톡 Mac CLI | `kakaotalk-mac` | macOS에서 카카오톡 대화 조회, 검색, 메시지 전송/삭제 | 불필요(로컬 앱/권한 필요) | [카카오톡 Mac CLI 가이드](docs/features/kakaotalk-mac.md) |
| 서울 지하철 도착정보 조회 | `seoul-subway-arrival` | 서울 지하철 역 기준 실시간 도착 예정 열차 확인 | 불필요 | [서울 지하철 도착정보 가이드](docs/features/seoul-subway-arrival.md) | | 서울 지하철 도착정보 조회 | `seoul-subway-arrival` | 서울 지하철 역 기준 실시간 도착 예정 열차 확인 | 불필요 | [서울 지하철 도착정보 가이드](docs/features/seoul-subway-arrival.md) |
| 서울 실시간 혼잡도 조회 | `seoul-density` | 서울 주요 121개 핫스팟의 실시간 혼잡도 단계와 추정 인구 조회 | 불필요 | [서울 실시간 혼잡도 가이드](docs/features/seoul-density.md) | | 서울 실시간 혼잡도 조회 | `seoul-density` | 서울 주요 121개 핫스팟의 실시간 혼잡도 단계와 추정 인구 조회 | 불필요 | [서울 실시간 혼잡도 가이드](docs/features/seoul-density.md) |
| 서울 따릉이 실시간 대여소 조회 | `seoul-bike` | 현재 좌표 주변 또는 대여소 이름 기준 따릉이 대여 가능 자전거와 빈 거치대 조회 | 불필요 | [서울 따릉이 실시간 대여소 가이드](docs/features/seoul-bike.md) |
| 한국 대중교통 길찾기 | `korean-transit-route` | ODsay LIVE API + Kakao geocoding 기반 출발지→도착지 지하철+버스+도보 경로 및 환승 정보 조회 | 필요 | [한국 대중교통 길찾기 가이드](docs/features/korean-transit-route.md) | | 한국 대중교통 길찾기 | `korean-transit-route` | ODsay LIVE API + Kakao geocoding 기반 출발지→도착지 지하철+버스+도보 경로 및 환승 정보 조회 | 필요 | [한국 대중교통 길찾기 가이드](docs/features/korean-transit-route.md) |
| 지하철 분실물 조회 | `subway-lost-property` | 지하철 역/물품명 기준 공식 LOST112 분실물 검색 조건과 유실물센터 진입점 안내 | 불필요 | [지하철 분실물 조회 가이드](docs/features/subway-lost-property.md) | | 지하철 분실물 조회 | `subway-lost-property` | 지하철 역/물품명 기준 공식 LOST112 분실물 검색 조건과 유실물센터 진입점 안내 | 불필요 | [지하철 분실물 조회 가이드](docs/features/subway-lost-property.md) |
| 긱뉴스 조회 | `geeknews-search` | GeekNews 공개 RSS/Atom 피드 기반 최신 글 목록, 검색, 상세 확인 | 불필요 | [긱뉴스 조회 가이드](docs/features/geeknews-search.md) | | 긱뉴스 조회 | `geeknews-search` | GeekNews 공개 RSS/Atom 피드 기반 최신 글 목록, 검색, 상세 확인 | 불필요 | [긱뉴스 조회 가이드](docs/features/geeknews-search.md) |

View file

@ -19,6 +19,9 @@ client/skill -> k-skill-proxy -> upstream public API
- `GET /v1/korea-weather/forecast` - `GET /v1/korea-weather/forecast`
- `GET /v1/seoul-subway/arrival` - `GET /v1/seoul-subway/arrival`
- `GET /v1/seoul-density/citydata` (서울 실시간 도시데이터 핫스팟 혼잡도/추정 인구, `SEOUL_OPEN_API_KEY`) - `GET /v1/seoul-density/citydata` (서울 실시간 도시데이터 핫스팟 혼잡도/추정 인구, `SEOUL_OPEN_API_KEY`)
- `GET /v1/seoul-bike/realtime` (서울 따릉이 실시간 대여정보 `bikeList`, `SEOUL_OPEN_API_KEY`)
- `GET /v1/seoul-bike/stations` (서울 따릉이 대여소 마스터 `tbCycleStationInfo`, `SEOUL_OPEN_API_KEY`)
- `GET /v1/seoul-bike/nearby` (좌표 주변 따릉이 실시간 대여소 필터링, `SEOUL_OPEN_API_KEY`)
- `GET /v1/han-river/water-level` - `GET /v1/han-river/water-level`
- `GET /v1/household-waste/info` (생활쓰레기 배출정보, `DATA_GO_KR_API_KEY`; 쿼리 `pageNo`·`numOfRows` 필수, 값 `1`·`100`) - `GET /v1/household-waste/info` (생활쓰레기 배출정보, `DATA_GO_KR_API_KEY`; 쿼리 `pageNo`·`numOfRows` 필수, 값 `1`·`100`)
- `GET /v1/mfds/drug-safety/lookup` (식약처 의약품개요정보 + 안전상비의약품 정보, `DATA_GO_KR_API_KEY`) - `GET /v1/mfds/drug-safety/lookup` (식약처 의약품개요정보 + 안전상비의약품 정보, `DATA_GO_KR_API_KEY`)
@ -125,6 +128,12 @@ curl -fsS --get "${BASE}/v1/seoul-subway/arrival" \
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}" BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
curl -fsS --get "${BASE}/v1/seoul-density/citydata" \ curl -fsS --get "${BASE}/v1/seoul-density/citydata" \
--data-urlencode 'area=강남역' --data-urlencode 'area=강남역'
# 서울 따릉이 주변 대여소
curl -fsS --get "${BASE}/v1/seoul-bike/nearby" \
--data-urlencode 'lat=37.5717' \
--data-urlencode 'lon=126.9763' \
--data-urlencode 'radius_m=500'
``` ```
한국 날씨 endpoint: 한국 날씨 endpoint:

View file

@ -0,0 +1,82 @@
# 서울 따릉이 실시간 대여소 조회 가이드
## 이 기능으로 할 수 있는 일
- 현재 좌표 주변 따릉이 대여소의 대여 가능 자전거 수 확인
- 빈 거치대 수(`rackTotCnt - parkingBikeTotCnt`) 확인
- 대여소 이름 키워드로 실시간 상태 검색
- 별도 사용자 `SEOUL_OPEN_API_KEY` 없이 `k-skill-proxy` 로 조회
## 먼저 필요한 것
- [공통 설정 가이드](../setup.md) 확인
## 기본 경로
기본적으로 `https://k-skill-proxy.nomadamas.org/v1/seoul-bike/*` 로 요청한다.
사용자는 별도 서울 열린데이터 광장 OpenAPI key 를 직접 발급받을 필요가 없다. upstream key 는 proxy 서버에서만 `SEOUL_OPEN_API_KEY` 로 관리한다.
`KSKILL_PROXY_BASE_URL` 환경변수가 있으면 그 값을 사용하고, 비우면 기본 hosted `https://k-skill-proxy.nomadamas.org` 를 쓴다.
## Proxy routes
| endpoint | upstream / 동작 | 주요 입력 |
|---|---|---|
| `GET /v1/seoul-bike/realtime` | 서울 열린데이터 광장 `bikeList` 실시간 대여정보 페이지 | `startIndex`, `endIndex` |
| `GET /v1/seoul-bike/stations` | 서울 열린데이터 광장 `tbCycleStationInfo` 대여소 마스터 페이지 | `startIndex`, `endIndex` |
| `GET /v1/seoul-bike/nearby` | proxy 가 realtime 행을 좌표 반경으로 필터링 | `lat`, `lon`, `radius_m`, `limit` |
## 기본 흐름
1. client/skill 은 기본 hosted path 또는 `KSKILL_PROXY_BASE_URL` 아래 `/v1/seoul-bike/nearby` endpoint 를 호출한다.
2. proxy 는 서울 열린데이터 광장 `bikeList``SEOUL_OPEN_API_KEY` 와 함께 호출한다.
3. proxy 는 좌표와 반경을 기준으로 대여소를 정렬하고 `available_bikes`, `empty_docks`, `distance_m` 을 반환한다.
4. 응답에는 `proxy.cache.hit`, `proxy.requested_at` 메타데이터가 붙는다.
## 예시
```bash
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
curl -fsS --get "${BASE}/v1/seoul-bike/nearby" \
--data-urlencode 'lat=37.5717' \
--data-urlencode 'lon=126.9763' \
--data-urlencode 'radius_m=500' \
--data-urlencode 'limit=5'
```
스킬 CLI 사용 예시:
```bash
python3 seoul-bike/scripts/seoul_bike.py nearby --lat 37.5717 --lon 126.9763 --radius-m 500
python3 seoul-bike/scripts/seoul_bike.py search "광화문" --limit 5
```
예상 응답 요약:
```text
따릉이 주변 대여소 2곳
기준 좌표: 37.5717, 126.9763 / 반경 500m
- 101. 광화문역 1번출구 앞: 대여 가능 4대, 빈 거치대 11개, 거리 0m
조회 시각: 2026-05-21T06:10:00.000Z
```
## fallback / 대체 흐름
- `KSKILL_PROXY_BASE_URL` 을 별도로 넣으면 해당 proxy 를 우선 사용한다.
- 기본 hosted path 는 `https://k-skill-proxy.nomadamas.org/v1/seoul-bike/*` 이다.
- self-host 운영자는 서버 쪽에만 `SEOUL_OPEN_API_KEY` 를 넣는다. 사용자 쪽에는 키가 필요 없다.
## 주의할 점
- 실시간 데이터는 계속 변하므로 답변에는 조회 시각을 함께 적는다.
- 예약/대여 자동화는 하지 않는다. 조회 전용 스킬이다.
- 서울 열린데이터 광장 quota 초과나 일시 장애가 있을 수 있다.
- 반경 안에 대여소가 없으면 `items: []` 가 정상적으로 반환될 수 있다.
## 참고 표면
- 서울 열린데이터 광장: `https://data.seoul.go.kr`
- 따릉이 실시간 대여정보: `bikeList`
- 따릉이 대여소 정보: `tbCycleStationInfo`
- proxy 운영 안내: [k-skill 프록시 서버 가이드](k-skill-proxy.md)

View file

@ -123,6 +123,7 @@ npx --yes skills add <owner/repo> \
--skill hipass-receipt \ --skill hipass-receipt \
--skill seoul-subway-arrival \ --skill seoul-subway-arrival \
--skill seoul-density \ --skill seoul-density \
--skill seoul-bike \
--skill subway-lost-property \ --skill subway-lost-property \
--skill geeknews-search \ --skill geeknews-search \
--skill korea-weather \ --skill korea-weather \
@ -401,6 +402,7 @@ node scripts/korean_character_count.js --text $'첫 줄\n둘째 줄🙂' --profi
- `ktx-booking` - `ktx-booking`
- `seoul-subway-arrival` - `seoul-subway-arrival`
- `seoul-density` - `seoul-density`
- `seoul-bike`
- `korea-weather` - `korea-weather`
- `fine-dust-location` - `fine-dust-location`
- `korean-law-search` - `korean-law-search`

View file

@ -109,9 +109,9 @@
- 장점: 모바일 주민등록증·운전면허증 발급 흐름 정리에 특화할 수 있다 - 장점: 모바일 주민등록증·운전면허증 발급 흐름 정리에 특화할 수 있다
- 이유: 한국 특화성이 강하고 가이드형 스킬로 출발하기 좋다 - 이유: 한국 특화성이 강하고 가이드형 스킬로 출발하기 좋다
#### 버스/지하철 도착정보 조회 #### 버스/지하철/따릉이 도착·가용정보 조회
- 장점: 주변 정류소, 지하철, 공항버스, 버스정보 조회까지 출퇴근 수요가 강하다 - 장점: 주변 정류소, 지하철, 공항버스, 버스정보, 따릉이 대여 가능 자전거/빈 거치대까지 출퇴근·라스트마일 수요가 강하다
- 이유: 이미 검증된 반복 조회 패턴이라 확장하기 쉽다 - 이유: 이미 검증된 반복 조회 패턴이라 확장하기 쉽다
#### 네이버 생활 허브 #### 네이버 생활 허브

View file

@ -38,7 +38,7 @@ KAKAO_REST_API_KEY=replace-me
KSKILL_PROXY_BASE_URL= KSKILL_PROXY_BASE_URL=
``` ```
서울 지하철 도착정보, 서울 실시간 혼잡도 조회, 한국 날씨 조회는 `KSKILL_PROXY_BASE_URL` 이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, KOSIS 일반 조회, Kakao Local geocoding, 의약품 안전 체크, 식품 안전 체크, 창업진흥원 K-Startup 조회도 기본 hosted proxy를 쓴다. 생활쓰레기 배출정보는 `k-skill-proxy``/v1/household-waste/info` 라우트를 거쳐 `serviceKey`만 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다. 서울 지하철 도착정보, 서울 실시간 혼잡도 조회, 서울 따릉이 실시간 대여소 조회, 한국 날씨 조회는 `KSKILL_PROXY_BASE_URL` 이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, KOSIS 일반 조회, Kakao Local geocoding, 의약품 안전 체크, 식품 안전 체크, 창업진흥원 K-Startup 조회도 기본 hosted proxy를 쓴다. 생활쓰레기 배출정보는 `k-skill-proxy``/v1/household-waste/info` 라우트를 거쳐 `serviceKey`만 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다.
## Missing secret handling policy ## Missing secret handling policy
@ -80,6 +80,6 @@ KSKILL_PROXY_BASE_URL=
- `KRX_API_KEY` - `KRX_API_KEY`
- `KSKILL_PROXY_BASE_URL` - `KSKILL_PROXY_BASE_URL`
`LAW_OC``korean-law-mcp` 가 법제처 Open API 를 호출할 때 쓰는 표준 변수명이다. 이 값은 로컬 CLI/로컬 MCP server 경로에서만 사용자 쪽에 필요하고, upstream remote MCP endpoint 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다. `DATA_GO_KR_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 키가 불필요하다. 생활쓰레기 배출정보 조회는 `k-skill-proxy``/v1/household-waste/info` 라우트를 거쳐 `serviceKey`(`DATA_GO_KR_API_KEY`)를 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다. 의약품 안전 체크도 `k-skill-proxy``/v1/mfds/drug-safety/lookup` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 식품 안전 체크는 `k-skill-proxy``/v1/mfds/food-safety/search` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 및 선택적 `FOODSAFETYKOREA_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 한국 주식 정보 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. `KRX_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. KOSIS 일반 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 KOSIS 키가 불필요하다. `KOSIS_API_KEY` 또는 `KSKILL_KOSIS_API_KEY` 는 self-host proxy 운영자, direct 호출, 또는 bigdata 호출 문맥에서만 쓴다. Kakao Local geocoding도 기본 hosted proxy를 경유하므로 사용자 쪽 `KAKAO_REST_API_KEY` 가 불필요하다. `KAKAO_REST_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. 근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다. `OPINET_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 창업진흥원 K-Startup 조회도 `k-skill-proxy``/v1/kstartup/*` 라우트를 거쳐 `ServiceKey`(`DATA_GO_KR_API_KEY`)를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. `KSKILL_KSTARTUP_API_KEY``--direct` 호출 문맥에서만 사용자 쪽에 둔다. `KIPRIS_PLUS_API_KEY` 는 한국 특허 정보 검색 helper가 KIPRIS Plus Open API에 보낼 `ServiceKey` 값을 담는 표준 변수명이다. 공공데이터포털에서 복사한 percent-encoded key도 helper가 한 번 정규화한 뒤 요청한다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY`, `KMA_OPEN_API_KEY`, `AIR_KOREA_OPEN_API_KEY`, `HRFCO_OPEN_API_KEY`, `OPINET_API_KEY`, `DATA_GO_KR_API_KEY`, `FOODSAFETYKOREA_API_KEY`, `KRX_API_KEY`, `KOSIS_API_KEY`, `KAKAO_REST_API_KEY` 를 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 은 별도 self-host proxy를 쓸 때만 넣는다. 서울 지하철, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, KOSIS 일반 조회, Kakao Local geocoding, 의약품 안전 체크, 식품 안전 체크는 이 값이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 사용한다. `LAW_OC``korean-law-mcp` 가 법제처 Open API 를 호출할 때 쓰는 표준 변수명이다. 이 값은 로컬 CLI/로컬 MCP server 경로에서만 사용자 쪽에 필요하고, upstream remote MCP endpoint 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다. `DATA_GO_KR_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 키가 불필요하다. 생활쓰레기 배출정보 조회는 `k-skill-proxy``/v1/household-waste/info` 라우트를 거쳐 `serviceKey`(`DATA_GO_KR_API_KEY`)를 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다. 의약품 안전 체크도 `k-skill-proxy``/v1/mfds/drug-safety/lookup` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 식품 안전 체크는 `k-skill-proxy``/v1/mfds/food-safety/search` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 및 선택적 `FOODSAFETYKOREA_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 한국 주식 정보 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. `KRX_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. KOSIS 일반 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 KOSIS 키가 불필요하다. `KOSIS_API_KEY` 또는 `KSKILL_KOSIS_API_KEY` 는 self-host proxy 운영자, direct 호출, 또는 bigdata 호출 문맥에서만 쓴다. Kakao Local geocoding도 기본 hosted proxy를 경유하므로 사용자 쪽 `KAKAO_REST_API_KEY` 가 불필요하다. `KAKAO_REST_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. 근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다. `OPINET_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 창업진흥원 K-Startup 조회도 `k-skill-proxy``/v1/kstartup/*` 라우트를 거쳐 `ServiceKey`(`DATA_GO_KR_API_KEY`)를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. `KSKILL_KSTARTUP_API_KEY``--direct` 호출 문맥에서만 사용자 쪽에 둔다. `KIPRIS_PLUS_API_KEY` 는 한국 특허 정보 검색 helper가 KIPRIS Plus Open API에 보낼 `ServiceKey` 값을 담는 표준 변수명이다. 공공데이터포털에서 복사한 percent-encoded key도 helper가 한 번 정규화한 뒤 요청한다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY`, `KMA_OPEN_API_KEY`, `AIR_KOREA_OPEN_API_KEY`, `HRFCO_OPEN_API_KEY`, `OPINET_API_KEY`, `DATA_GO_KR_API_KEY`, `FOODSAFETYKOREA_API_KEY`, `KRX_API_KEY`, `KOSIS_API_KEY`, `KAKAO_REST_API_KEY` 를 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 은 별도 self-host proxy를 쓸 때만 넣는다. 서울 지하철, 서울 실시간 혼잡도, 서울 따릉이, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, KOSIS 일반 조회, Kakao Local geocoding, 의약품 안전 체크, 식품 안전 체크는 이 값이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 사용한다.
이 레포의 credential-bearing skill은 전부 이 정책을 전제로 작성한다. 자세한 공통 설치 절차는 [공통 설정 가이드](setup.md)를 본다. 이 레포의 credential-bearing skill은 전부 이 정책을 전제로 작성한다. 자세한 공통 설치 절차는 [공통 설정 가이드](setup.md)를 본다.

View file

@ -42,7 +42,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
실제 값을 채운다. 실제 값을 채운다.
서울 지하철 도착정보, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다. KOSIS 일반 조회와 Kakao Local geocoding도 같은 기본 hosted path를 쓴다. 별도 self-host proxy를 쓸 때만 `KSKILL_PROXY_BASE_URL` 을 채운다. 서울 지하철 도착정보, 서울 실시간 혼잡도 조회, 서울 따릉이 실시간 대여소 조회, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다. KOSIS 일반 조회와 Kakao Local geocoding도 같은 기본 hosted path를 쓴다. 별도 self-host proxy를 쓸 때만 `KSKILL_PROXY_BASE_URL` 을 채운다.
한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC``korean-law-mcp` 로컬 실행에 쓴다. 로컬 CLI/MCP 경로는 `LAW_OC` 를 채운 뒤 `npm install -g korean-law-mcp``korean-law list` 로 설치 상태를 확인한다. 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC``korean-law-mcp` 로컬 실행에 쓴다. 로컬 CLI/MCP 경로는 `LAW_OC` 를 채운 뒤 `npm install -g korean-law-mcp``korean-law list` 로 설치 상태를 확인한다.
@ -89,6 +89,7 @@ bash scripts/check-setup.sh
| 근처 가장 싼 주유소 찾기 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) | | 근처 가장 싼 주유소 찾기 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
| 서울 지하철 도착정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `SEOUL_OPEN_API_KEY`) | | 서울 지하철 도착정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `SEOUL_OPEN_API_KEY`) |
| 서울 실시간 혼잡도 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `SEOUL_OPEN_API_KEY`) | | 서울 실시간 혼잡도 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `SEOUL_OPEN_API_KEY`) |
| 서울 따릉이 실시간 대여소 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `SEOUL_OPEN_API_KEY`) |
| 한국 날씨 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KMA_OPEN_API_KEY`) | | 한국 날씨 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KMA_OPEN_API_KEY`) |
| 사용자 위치 미세먼지 조회 | `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY` | | 사용자 위치 미세먼지 조회 | `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY` |
| 한강 수위 정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) | | 한강 수위 정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |

View file

@ -176,6 +176,7 @@
- 공중화장실정보 지역별 CSV: https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=<시도코드> - 공중화장실정보 지역별 CSV: https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=<시도코드>
- 서울특별시 지하철 실시간 도착정보: https://www.data.go.kr/data/15058052/openapi.do - 서울특별시 지하철 실시간 도착정보: https://www.data.go.kr/data/15058052/openapi.do
- 서울 실시간 도시데이터(`citydata_ppltn`): https://data.seoul.go.kr/dataList/OA-21778/A/1/datasetView.do - 서울 실시간 도시데이터(`citydata_ppltn`): https://data.seoul.go.kr/dataList/OA-21778/A/1/datasetView.do
- 서울 공공자전거 따릉이 실시간 대여정보(`bikeList`) 및 대여소 정보(`tbCycleStationInfo`): https://data.seoul.go.kr
- 경찰청 LOST112 습득물 목록: https://www.lost112.go.kr/find/findList.do - 경찰청 LOST112 습득물 목록: https://www.lost112.go.kr/find/findList.do
- 서울교통공사 유실물센터: https://www.seoulmetro.co.kr/kr/page.do?menuIdx=541 - 서울교통공사 유실물센터: https://www.seoulmetro.co.kr/kr/page.do?menuIdx=541
- GeekNews public RSS/Atom feed: https://feeds.feedburner.com/geeknews-feed - GeekNews public RSS/Atom feed: https://feeds.feedburner.com/geeknews-feed

View file

@ -79,7 +79,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
유저에게 물어서 실제 값을 채운다. 유저에게 물어서 실제 값을 채운다.
서울 지하철 도착정보, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다. 별도 self-host proxy를 쓸 때만 `KSKILL_PROXY_BASE_URL` 을 채운다. 서울 지하철 도착정보, 서울 실시간 혼잡도 조회, 서울 따릉이 실시간 대여소 조회, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다. 별도 self-host proxy를 쓸 때만 `KSKILL_PROXY_BASE_URL` 을 채운다.
한국 법령 검색은 로컬 `korean-law-mcp` 경로를 쓸 때만 `LAW_OC` 를 채운다. remote endpoint는 사용자 `LAW_OC` 없이 `url`만 등록하면 되고, 기존 경로 장애 시에는 `법망`(`https://api.beopmang.org`)을 fallback으로 안내한다. 한국 법령 검색은 로컬 `korean-law-mcp` 경로를 쓸 때만 `LAW_OC` 를 채운다. remote endpoint는 사용자 `LAW_OC` 없이 `url`만 등록하면 되고, 기존 경로 장애 시에는 `법망`(`https://api.beopmang.org`)을 fallback으로 안내한다.

View file

@ -10,9 +10,9 @@
"scripts": { "scripts": {
"build": "npm run build --workspaces --if-present", "build": "npm run build --workspaces --if-present",
"build:manus-bundle": "node scripts/build-manus-bundle.js", "build:manus-bundle": "node scripts/build-manus-bundle.js",
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/nts_business_registration.py scripts/test_nts_business_registration.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py nts-business-registration/scripts/nts_business_registration.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/test_ohou_today_deal.py scripts/ticket_availability.py scripts/test_ticket_availability.py scripts/test_danawa_price_search.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py ohou-today-deal/scripts/ohou_today_deal.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh", "lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/nts_business_registration.py scripts/test_nts_business_registration.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py nts-business-registration/scripts/nts_business_registration.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/test_ohou_today_deal.py scripts/ticket_availability.py scripts/test_ticket_availability.py scripts/test_danawa_price_search.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py ohou-today-deal/scripts/ohou_today_deal.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py seoul-bike/scripts/seoul_bike.py scripts/test_seoul_bike.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "python3 -m pip install --user --quiet beautifulsoup4 && node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_build_manus_bundle.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_nts_business_registration scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper scripts.test_ohou_today_deal scripts.test_ticket_availability scripts.test_danawa_price_search && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts python3 -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && PYTHONPATH=.:scripts:kstartup-search/scripts python3 -m unittest discover -s kstartup-search/tests -p 'test_run_kstartup.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh", "test": "python3 -m pip install --user --quiet beautifulsoup4 && node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_build_manus_bundle.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_nts_business_registration scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper scripts.test_ohou_today_deal scripts.test_ticket_availability scripts.test_seoul_bike scripts.test_danawa_price_search && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts python3 -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && PYTHONPATH=.:scripts:kstartup-search/scripts python3 -m unittest discover -s kstartup-search/tests -p 'test_run_kstartup.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace court-auction-notice-search --dry-run && npm pack --workspace donation-place-search --dry-run && npm pack --workspace gongsijiga-search --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run && npm pack --workspace k-skill-rhwp --dry-run && npm pack --workspace korean-marathon-schedule --dry-run && npm pack --workspace gangnamunni-clinic-search --dry-run && npm pack --workspace daishin-report-search --dry-run && npm pack --workspace sh-notice-search --dry-run && npm pack --workspace emergency-room-beds --dry-run && npm pack --workspace local-election-candidate-search --dry-run", "pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace court-auction-notice-search --dry-run && npm pack --workspace donation-place-search --dry-run && npm pack --workspace gongsijiga-search --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run && npm pack --workspace k-skill-rhwp --dry-run && npm pack --workspace korean-marathon-schedule --dry-run && npm pack --workspace gangnamunni-clinic-search --dry-run && npm pack --workspace daishin-report-search --dry-run && npm pack --workspace sh-notice-search --dry-run && npm pack --workspace emergency-room-beds --dry-run && npm pack --workspace local-election-candidate-search --dry-run",
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run", "ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
"version-packages": "changeset version", "version-packages": "changeset version",

View file

@ -9,6 +9,9 @@
- `GET /v1/korea-weather/forecast` - `GET /v1/korea-weather/forecast`
- `GET /v1/seoul-subway/arrival` - `GET /v1/seoul-subway/arrival`
- `GET /v1/seoul-density/citydata` — 서울 실시간 도시데이터(`citydata_ppltn`) 핫스팟 혼잡도/추정 인구(`SEOUL_OPEN_API_KEY`) - `GET /v1/seoul-density/citydata` — 서울 실시간 도시데이터(`citydata_ppltn`) 핫스팟 혼잡도/추정 인구(`SEOUL_OPEN_API_KEY`)
- `GET /v1/seoul-bike/realtime` — 서울 따릉이 실시간 대여정보(`bikeList`, `SEOUL_OPEN_API_KEY`)
- `GET /v1/seoul-bike/stations` — 서울 따릉이 대여소 마스터(`tbCycleStationInfo`, `SEOUL_OPEN_API_KEY`)
- `GET /v1/seoul-bike/nearby` — 좌표 주변 따릉이 실시간 대여소 필터링(`SEOUL_OPEN_API_KEY`)
- `GET /v1/han-river/water-level` - `GET /v1/han-river/water-level`
- `GET /v1/household-waste/info` — 생활쓰레기 배출정보(`DATA_GO_KR_API_KEY`; `pageNo=1`, `numOfRows=100` 필수) - `GET /v1/household-waste/info` — 생활쓰레기 배출정보(`DATA_GO_KR_API_KEY`; `pageNo=1`, `numOfRows=100` 필수)
- `GET /v1/parking-lots/search` — 전국주차장정보표준데이터 기반 근처 공영주차장 검색(`DATA_GO_KR_API_KEY`) - `GET /v1/parking-lots/search` — 전국주차장정보표준데이터 기반 근처 공영주차장 검색(`DATA_GO_KR_API_KEY`)
@ -100,6 +103,12 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/seoul-subway/arrival' \
```bash ```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/seoul-density/citydata' \ curl -fsS --get 'http://127.0.0.1:4020/v1/seoul-density/citydata' \
--data-urlencode 'area=강남역' --data-urlencode 'area=강남역'
# Seoul Bike nearby stations
curl -fsS --get 'http://127.0.0.1:4020/v1/seoul-bike/nearby' \
--data-urlencode 'lat=37.5717' \
--data-urlencode 'lon=126.9763' \
--data-urlencode 'radius_m=500'
``` ```
한국 날씨 예시: 한국 날씨 예시:

View file

@ -499,6 +499,212 @@ function normalizeSeoulCityDataQuery(query) {
return { area }; return { area };
} }
function parseBoundedIntegerAlias(query, keys, { defaultValue, min, max, label }) {
let raw;
for (const key of keys) {
if (query[key] !== undefined) {
raw = query[key];
break;
}
}
let value = defaultValue;
if (raw !== undefined) {
if (typeof raw !== "string" || !/^[+-]?\d+$/.test(raw)) {
throw new Error(`Provide valid ${label}.`);
}
value = Number(raw);
}
if (!Number.isInteger(value) || value < min || value > max) {
throw new Error(`Provide valid ${label}.`);
}
return value;
}
function parseNumberAlias(query, keys, { min, max, label }) {
let raw;
for (const key of keys) {
if (query[key] !== undefined) {
raw = query[key];
break;
}
}
if (typeof raw !== "string" || raw.trim() === "") {
throw new Error(`Provide valid ${label}.`);
}
const value = Number(raw.trim());
if (!Number.isFinite(value) || value < min || value > max) {
throw new Error(`Provide valid ${label}.`);
}
return value;
}
function normalizeSeoulBikePageQuery(query = {}) {
const startIndex = parseBoundedIntegerAlias(query, ["startIndex", "start_index", "start"], {
defaultValue: 1,
min: 1,
max: 100000,
label: "startIndex"
});
const endIndex = parseBoundedIntegerAlias(query, ["endIndex", "end_index", "end"], {
defaultValue: 1000,
min: 1,
max: 100000,
label: "endIndex"
});
if (endIndex < startIndex || endIndex - startIndex > 999) {
throw new Error("Provide valid startIndex and endIndex.");
}
return { startIndex, endIndex };
}
function normalizeSeoulBikeNearbyQuery(query = {}) {
const latitude = parseNumberAlias(query, ["latitude", "lat", "y"], {
min: -90,
max: 90,
label: "latitude"
});
const longitude = parseNumberAlias(query, ["longitude", "lon", "lng", "x"], {
min: -180,
max: 180,
label: "longitude"
});
const radiusMeters = parseBoundedIntegerAlias(query, ["radiusMeters", "radius_m", "radius"], {
defaultValue: 500,
min: 1,
max: 5000,
label: "radiusMeters"
});
const limit = parseBoundedIntegerAlias(query, ["limit"], {
defaultValue: 10,
min: 1,
max: 50,
label: "limit"
});
return { latitude, longitude, radiusMeters, limit };
}
function parseNullableNumber(value) {
if (value === undefined || value === null || value === "") {
return null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function haversineDistanceMeters(aLat, aLon, bLat, bLon) {
const earthRadiusMeters = 6371008.8;
const toRad = (degrees) => (degrees * Math.PI) / 180;
const dLat = toRad(bLat - aLat);
const dLon = toRad(bLon - aLon);
const lat1 = toRad(aLat);
const lat2 = toRad(bLat);
const a = Math.sin(dLat / 2) ** 2
+ (Math.cos(lat1) * Math.cos(lat2) * (Math.sin(dLon / 2) ** 2));
return earthRadiusMeters * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function normalizeSeoulBikeRealtimeRow(row, origin = null) {
const latitude = parseNullableNumber(row.stationLatitude ?? row.latitude ?? row.lat);
const longitude = parseNullableNumber(row.stationLongitude ?? row.longitude ?? row.lon ?? row.lng);
const rackTotalCount = parseNullableNumber(row.rackTotCnt ?? row.rack_total_count);
const availableBikes = parseNullableNumber(row.parkingBikeTotCnt ?? row.available_bikes);
const sharedPercent = parseNullableNumber(row.shared ?? row.shared_percent);
const emptyDocks = rackTotalCount === null || availableBikes === null
? null
: Math.max(0, rackTotalCount - availableBikes);
const distanceMeters = origin && latitude !== null && longitude !== null
? Math.round(haversineDistanceMeters(origin.latitude, origin.longitude, latitude, longitude))
: null;
return {
station_id: row.stationId ?? row.station_id ?? null,
station_name: row.stationName ?? row.station_name ?? null,
rack_total_count: rackTotalCount,
available_bikes: availableBikes,
empty_docks: emptyDocks,
shared_percent: sharedPercent,
latitude,
longitude,
distance_m: distanceMeters
};
}
function extractSeoulBikeRows(payload) {
const status = payload && payload.rentBikeStatus;
if (!status || !Array.isArray(status.row)) {
return [];
}
return status.row;
}
function getSeoulOpenApiResultCode(result) {
return result?.CODE ?? result?.["RESULT.CODE"] ?? result?.code ?? null;
}
function getSeoulOpenApiResultMessage(result) {
return result?.MESSAGE ?? result?.["RESULT.MESSAGE"] ?? result?.message ?? null;
}
function findSeoulOpenApiResultEnvelope(payload) {
if (!payload || typeof payload !== "object") {
return null;
}
if (payload.RESULT && typeof payload.RESULT === "object") {
return payload.RESULT;
}
for (const value of Object.values(payload)) {
if (value && typeof value === "object" && value.RESULT && typeof value.RESULT === "object") {
return value.RESULT;
}
}
return null;
}
function getSeoulOpenApiSemanticError(payload) {
const result = findSeoulOpenApiResultEnvelope(payload);
const code = getSeoulOpenApiResultCode(result);
if (!code) {
return null;
}
const normalizedCode = String(code).toUpperCase();
if (normalizedCode.startsWith("INFO-")) {
return null;
}
return {
code: String(code),
message: getSeoulOpenApiResultMessage(result) || "Seoul Open API returned an application-level error."
};
}
function buildSeoulBikeSemanticErrorPayload(error, config) {
return {
error: "upstream_semantic_error",
message: "Seoul Bike upstream returned an application-level error.",
upstream: {
code: error.code,
message: error.message
},
proxy: {
name: config.proxyName,
cache: { hit: false, ttl_ms: config.cacheTtlMs },
requested_at: new Date().toISOString()
}
};
}
function buildSeoulBikeUpstreamErrorPayload(config) {
return {
error: "upstream_error",
message: "Seoul Bike upstream request failed.",
proxy: {
name: config.proxyName,
cache: { hit: false, ttl_ms: config.cacheTtlMs },
requested_at: new Date().toISOString()
}
};
}
function normalizeKosisSearchQuery(query) { function normalizeKosisSearchQuery(query) {
const searchNm = trimOrNull(query.searchNm ?? query.search_nm ?? query.query ?? query.q); const searchNm = trimOrNull(query.searchNm ?? query.search_nm ?? query.query ?? query.q);
if (!searchNm) { if (!searchNm) {
@ -1100,6 +1306,89 @@ async function proxySeoulCityDataRequest({
}; };
} }
function seoulOpenApiNotConfiguredResponse() {
return {
statusCode: 503,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "upstream_not_configured",
message: "SEOUL_OPEN_API_KEY is not configured on the proxy server."
})
};
}
async function proxySeoulBikeDatasetRequest({
dataset,
startIndex = 1,
endIndex = 1000,
apiKey,
fetchImpl = global.fetch
}) {
if (!apiKey) {
return seoulOpenApiNotConfiguredResponse();
}
const url = new URL(
`${SEOUL_CITYDATA_BASE_URL}/${apiKey}/json/${dataset}/${startIndex}/${endIndex}/`
);
const response = await fetchImpl(url, {
signal: AbortSignal.timeout(20000)
});
return {
statusCode: response.status,
contentType: response.headers.get("content-type") || "application/json; charset=utf-8",
body: await response.text()
};
}
async function proxySeoulBikeRealtimeRequest(options) {
return proxySeoulBikeDatasetRequest({ ...options, dataset: "bikeList" });
}
async function proxySeoulBikeStationsRequest(options) {
return proxySeoulBikeDatasetRequest({ ...options, dataset: "tbCycleStationInfo" });
}
async function fetchAllSeoulBikeRealtimeRows({ apiKey, fetchImpl = global.fetch }) {
const first = await proxySeoulBikeRealtimeRequest({
startIndex: 1,
endIndex: 1000,
apiKey,
fetchImpl
});
if (first.statusCode !== 200 || !first.contentType.includes("json")) {
return { upstream: first, rows: null };
}
const payload = JSON.parse(first.body);
const semanticError = getSeoulOpenApiSemanticError(payload);
if (semanticError) {
return { upstream: first, rows: null, semanticError };
}
const rows = extractSeoulBikeRows(payload);
const totalCount = Number(payload.rentBikeStatus?.list_total_count ?? rows.length);
const safeTotalCount = Number.isFinite(totalCount) ? Math.max(totalCount, rows.length) : rows.length;
for (let startIndex = 1001; startIndex <= safeTotalCount; startIndex += 1000) {
const endIndex = Math.min(startIndex + 999, safeTotalCount);
const next = await proxySeoulBikeRealtimeRequest({ startIndex, endIndex, apiKey, fetchImpl });
if (next.statusCode !== 200 || !next.contentType.includes("json")) {
return { upstream: next, rows: null };
}
const nextPayload = JSON.parse(next.body);
const nextSemanticError = getSeoulOpenApiSemanticError(nextPayload);
if (nextSemanticError) {
return { upstream: next, rows: null, semanticError: nextSemanticError };
}
rows.push(...extractSeoulBikeRows(nextPayload));
}
return { upstream: first, rows };
}
async function proxyKmaWeatherRequest({ async function proxyKmaWeatherRequest({
baseDate, baseDate,
baseTime, baseTime,
@ -1699,6 +1988,213 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
return payload; return payload;
}); });
app.get("/v1/seoul-bike/realtime", async (request, reply) => {
let normalized;
try {
normalized = normalizeSeoulBikePageQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({ route: "seoul-bike-realtime", ...normalized });
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: { hit: true, ttl_ms: config.cacheTtlMs }
}
};
}
let upstream;
try {
upstream = await proxySeoulBikeRealtimeRequest({
...normalized,
apiKey: config.seoulOpenApiKey
});
} catch {
reply.code(502);
return buildSeoulBikeUpstreamErrorPayload(config);
}
reply.code(upstream.statusCode);
reply.header("content-type", upstream.contentType);
if (!upstream.contentType.includes("json")) {
return upstream.body;
}
let payload;
try {
payload = JSON.parse(upstream.body);
} catch {
reply.code(502);
return buildSeoulBikeUpstreamErrorPayload(config);
}
const semanticError = getSeoulOpenApiSemanticError(payload);
if (semanticError) {
reply.code(502);
return buildSeoulBikeSemanticErrorPayload(semanticError, config);
}
payload.proxy = {
name: config.proxyName,
cache: { hit: false, ttl_ms: config.cacheTtlMs },
requested_at: new Date().toISOString()
};
if (upstream.statusCode >= 200 && upstream.statusCode < 300) {
cache.set(cacheKey, payload, config.cacheTtlMs);
}
return payload;
});
app.get("/v1/seoul-bike/stations", async (request, reply) => {
let normalized;
try {
normalized = normalizeSeoulBikePageQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({ route: "seoul-bike-stations", ...normalized });
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: { hit: true, ttl_ms: config.cacheTtlMs }
}
};
}
let upstream;
try {
upstream = await proxySeoulBikeStationsRequest({
...normalized,
apiKey: config.seoulOpenApiKey
});
} catch {
reply.code(502);
return buildSeoulBikeUpstreamErrorPayload(config);
}
reply.code(upstream.statusCode);
reply.header("content-type", upstream.contentType);
if (!upstream.contentType.includes("json")) {
return upstream.body;
}
let payload;
try {
payload = JSON.parse(upstream.body);
} catch {
reply.code(502);
return buildSeoulBikeUpstreamErrorPayload(config);
}
const semanticError = getSeoulOpenApiSemanticError(payload);
if (semanticError) {
reply.code(502);
return buildSeoulBikeSemanticErrorPayload(semanticError, config);
}
payload.proxy = {
name: config.proxyName,
cache: { hit: false, ttl_ms: config.cacheTtlMs },
requested_at: new Date().toISOString()
};
if (upstream.statusCode >= 200 && upstream.statusCode < 300) {
cache.set(cacheKey, payload, config.cacheTtlMs);
}
return payload;
});
app.get("/v1/seoul-bike/nearby", async (request, reply) => {
let normalized;
try {
normalized = normalizeSeoulBikeNearbyQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({ route: "seoul-bike-nearby", ...normalized });
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: { hit: true, ttl_ms: config.cacheTtlMs }
}
};
}
let realtimeResult;
try {
realtimeResult = await fetchAllSeoulBikeRealtimeRows({
apiKey: config.seoulOpenApiKey
});
} catch {
reply.code(502);
return buildSeoulBikeUpstreamErrorPayload(config);
}
const { upstream, rows, semanticError } = realtimeResult;
reply.code(upstream.statusCode);
reply.header("content-type", upstream.contentType);
if (semanticError) {
reply.code(502);
return buildSeoulBikeSemanticErrorPayload(semanticError, config);
}
if (!upstream.contentType.includes("json") || rows === null) {
return upstream.body;
}
const origin = { latitude: normalized.latitude, longitude: normalized.longitude };
const items = rows
.map((row) => normalizeSeoulBikeRealtimeRow(row, origin))
.filter((row) => row.latitude !== null && row.longitude !== null && row.distance_m !== null)
.filter((row) => row.distance_m <= normalized.radiusMeters)
.sort((a, b) => a.distance_m - b.distance_m)
.slice(0, normalized.limit);
const payload = {
query: {
latitude: normalized.latitude,
longitude: normalized.longitude,
radius_m: normalized.radiusMeters,
limit: normalized.limit
},
count: items.length,
items,
proxy: {
name: config.proxyName,
cache: { hit: false, ttl_ms: config.cacheTtlMs },
requested_at: new Date().toISOString()
}
};
if (upstream.statusCode >= 200 && upstream.statusCode < 300) {
cache.set(cacheKey, payload, config.cacheTtlMs);
}
return payload;
});
app.get("/v1/seoul-subway/arrival", async (request, reply) => { app.get("/v1/seoul-subway/arrival", async (request, reply) => {
let normalized; let normalized;
@ -4315,6 +4811,8 @@ module.exports = {
normalizeParkingLotSearchQuery, normalizeParkingLotSearchQuery,
normalizeRealEstateQuery, normalizeRealEstateQuery,
normalizeRegionCodeQuery, normalizeRegionCodeQuery,
normalizeSeoulBikeNearbyQuery,
normalizeSeoulBikePageQuery,
normalizeSeoulCityDataQuery, normalizeSeoulCityDataQuery,
normalizeSeoulSubwayQuery, normalizeSeoulSubwayQuery,
proxyAirKoreaRequest, proxyAirKoreaRequest,
@ -4328,6 +4826,8 @@ module.exports = {
proxyKstartupRequest, proxyKstartupRequest,
fetchNaverShoppingSearch, fetchNaverShoppingSearch,
proxyOpinetRequest, proxyOpinetRequest,
proxySeoulBikeRealtimeRequest,
proxySeoulBikeStationsRequest,
proxySeoulCityDataRequest, proxySeoulCityDataRequest,
proxySeoulSubwayRequest, proxySeoulSubwayRequest,
resolveLatestKmaForecastBase, resolveLatestKmaForecastBase,

View file

@ -24,6 +24,8 @@ const {
proxyKakaoLocalRequest, proxyKakaoLocalRequest,
proxyKosisRequest, proxyKosisRequest,
proxyKmaWeatherRequest, proxyKmaWeatherRequest,
proxySeoulBikeRealtimeRequest,
proxySeoulBikeStationsRequest,
proxySeoulCityDataRequest, proxySeoulCityDataRequest,
proxySeoulSubwayRequest proxySeoulSubwayRequest
} = require("../src/server"); } = require("../src/server");
@ -1787,6 +1789,370 @@ test("proxySeoulCityDataRequest injects API key and encodes area name", async ()
assert.match(calledUrl, /\/test-seoul-key\/json\/citydata_ppltn\/1\/1\/%EA%B0%95%EB%82%A8%EC%97%AD$/); assert.match(calledUrl, /\/test-seoul-key\/json\/citydata_ppltn\/1\/1\/%EA%B0%95%EB%82%A8%EC%97%AD$/);
}); });
test("seoul bike realtime endpoint caches successful upstream responses for normalized page queries", async (t) => {
const originalFetch = global.fetch;
let fetchCalls = 0;
global.fetch = async () => {
fetchCalls += 1;
return new Response(
JSON.stringify({
rentBikeStatus: {
list_total_count: 1,
RESULT: { CODE: "INFO-000", MESSAGE: "정상 처리되었습니다." },
row: [
{
stationId: "ST-10",
stationName: "101. 광화문역 1번출구 앞",
rackTotCnt: "15",
parkingBikeTotCnt: "4",
shared: "27",
stationLatitude: "37.5717",
stationLongitude: "126.9763"
}
]
}
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({
env: {
SEOUL_OPEN_API_KEY: "seoul-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const first = await app.inject({ method: "GET", url: "/v1/seoul-bike/realtime?start_index=1&end_index=10" });
const second = await app.inject({ method: "GET", url: "/v1/seoul-bike/realtime?startIndex=1&endIndex=10" });
assert.equal(first.statusCode, 200);
assert.equal(second.statusCode, 200);
assert.equal(fetchCalls, 1);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(first.json().rentBikeStatus.row[0].parkingBikeTotCnt, "4");
});
test("seoul bike routes stay publicly callable without proxy auth", async (t) => {
const originalFetch = global.fetch;
const calledUrls = [];
global.fetch = async (url) => {
calledUrls.push(String(url));
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
};
const app = buildServer({ env: { SEOUL_OPEN_API_KEY: "seoul-key" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const realtime = await app.inject({ method: "GET", url: "/v1/seoul-bike/realtime" });
const stations = await app.inject({ method: "GET", url: "/v1/seoul-bike/stations?startIndex=2&endIndex=5" });
assert.equal(realtime.statusCode, 200);
assert.equal(stations.statusCode, 200);
assert.match(calledUrls[0], /\/seoul-key\/json\/bikeList\/1\/1000\/?$/);
assert.match(calledUrls[1], /\/seoul-key\/json\/tbCycleStationInfo\/2\/5\/?$/);
});
test("seoul bike routes return 503 when proxy server lacks Seoul API key", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
for (const url of [
"/v1/seoul-bike/realtime",
"/v1/seoul-bike/stations",
"/v1/seoul-bike/nearby?lat=37.5717&lon=126.9763"
]) {
const response = await app.inject({ method: "GET", url });
assert.equal(response.statusCode, 503, url);
assert.equal(response.json().error, "upstream_not_configured");
}
});
test("proxySeoulBikeRealtimeRequest and stations request inject API key and preserve paging", async () => {
const calledUrls = [];
const fetchImpl = async (url) => {
calledUrls.push(String(url));
return new Response('{"ok":true}', {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
};
const realtime = await proxySeoulBikeRealtimeRequest({
startIndex: 2,
endIndex: 5,
apiKey: "test-seoul-key",
fetchImpl
});
const stations = await proxySeoulBikeStationsRequest({
startIndex: 3,
endIndex: 7,
apiKey: "test-seoul-key",
fetchImpl
});
assert.equal(realtime.statusCode, 200);
assert.equal(stations.statusCode, 200);
assert.match(calledUrls[0], /\/test-seoul-key\/json\/bikeList\/2\/5\/?$/);
assert.match(calledUrls[1], /\/test-seoul-key\/json\/tbCycleStationInfo\/3\/7\/?$/);
});
test("seoul bike nearby endpoint filters and sorts realtime stations by distance", async (t) => {
const originalFetch = global.fetch;
let fetchCalls = 0;
global.fetch = async () => {
fetchCalls += 1;
return new Response(
JSON.stringify({
rentBikeStatus: {
list_total_count: 3,
RESULT: { CODE: "INFO-000", MESSAGE: "정상 처리되었습니다." },
row: [
{
stationId: "ST-FAR",
stationName: "999. 먼 대여소",
rackTotCnt: "20",
parkingBikeTotCnt: "10",
shared: "50",
stationLatitude: "37.5900",
stationLongitude: "126.9900"
},
{
stationId: "ST-NEAR",
stationName: "101. 광화문역 1번출구 앞",
rackTotCnt: "15",
parkingBikeTotCnt: "4",
shared: "27",
stationLatitude: "37.5717",
stationLongitude: "126.9763"
},
{
stationId: "ST-EMPTY",
stationName: "102. 세종대로 앞",
rackTotCnt: "12",
parkingBikeTotCnt: "0",
shared: "0",
stationLatitude: "37.5720",
stationLongitude: "126.9770"
}
]
}
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({
env: {
SEOUL_OPEN_API_KEY: "seoul-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const first = await app.inject({
method: "GET",
url: "/v1/seoul-bike/nearby?lat=37.5717&lon=126.9763&radius_m=120&limit=5"
});
const second = await app.inject({
method: "GET",
url: "/v1/seoul-bike/nearby?latitude=37.5717&longitude=126.9763&radiusMeters=120&limit=5"
});
assert.equal(first.statusCode, 200);
assert.equal(second.statusCode, 200);
assert.equal(fetchCalls, 1);
const body = first.json();
assert.equal(body.count, 2);
assert.deepEqual(body.items.map((item) => item.station_id), ["ST-NEAR", "ST-EMPTY"]);
assert.equal(body.items[0].available_bikes, 4);
assert.equal(body.items[0].empty_docks, 11);
assert.equal(body.items[1].available_bikes, 0);
assert.equal(second.json().proxy.cache.hit, true);
});
test("seoul bike nearby endpoint returns non-cacheable error for upstream semantic failures", async (t) => {
const originalFetch = global.fetch;
let fetchCalls = 0;
global.fetch = async () => {
fetchCalls += 1;
return new Response(
JSON.stringify({
RESULT: { CODE: "ERROR-336", MESSAGE: "limit exceeded" }
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({
env: {
SEOUL_OPEN_API_KEY: "seoul-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const first = await app.inject({
method: "GET",
url: "/v1/seoul-bike/nearby?lat=37.5717&lon=126.9763&radius_m=120&limit=5"
});
const second = await app.inject({
method: "GET",
url: "/v1/seoul-bike/nearby?lat=37.5717&lon=126.9763&radius_m=120&limit=5"
});
assert.equal(first.statusCode, 502);
assert.equal(second.statusCode, 502);
assert.equal(fetchCalls, 2, "semantic upstream errors must not be cached");
assert.equal(first.json().error, "upstream_semantic_error");
assert.equal(first.json().upstream.code, "ERROR-336");
assert.equal(first.json().proxy.cache.hit, false);
});
test("seoul bike realtime and stations endpoints do not cache upstream semantic failures", async (t) => {
const originalFetch = global.fetch;
let fetchCalls = 0;
global.fetch = async () => {
fetchCalls += 1;
return new Response(
JSON.stringify({
rentBikeStatus: {
RESULT: { CODE: "ERROR-336", MESSAGE: "limit exceeded" }
}
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({
env: {
SEOUL_OPEN_API_KEY: "seoul-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const realtimeFirst = await app.inject({ method: "GET", url: "/v1/seoul-bike/realtime?startIndex=1&endIndex=10" });
const realtimeSecond = await app.inject({ method: "GET", url: "/v1/seoul-bike/realtime?startIndex=1&endIndex=10" });
const stations = await app.inject({ method: "GET", url: "/v1/seoul-bike/stations?startIndex=1&endIndex=10" });
assert.equal(realtimeFirst.statusCode, 502);
assert.equal(realtimeSecond.statusCode, 502);
assert.equal(stations.statusCode, 502);
assert.equal(fetchCalls, 3, "semantic upstream errors must not be cached across Seoul Bike passthrough routes");
assert.equal(realtimeFirst.json().error, "upstream_semantic_error");
assert.equal(stations.json().upstream.code, "ERROR-336");
});
test("seoul bike routes sanitize upstream fetch failures without leaking API keys", async (t) => {
const originalFetch = global.fetch;
const secret = "SECRETSEOULKEY";
let fetchCalls = 0;
global.fetch = async (url) => {
fetchCalls += 1;
throw new Error(`network failure ${url}`);
};
const app = buildServer({
env: {
SEOUL_OPEN_API_KEY: secret,
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
for (const url of [
"/v1/seoul-bike/realtime?startIndex=1&endIndex=1",
"/v1/seoul-bike/stations?startIndex=1&endIndex=1",
"/v1/seoul-bike/nearby?lat=37.5717&lon=126.9763&radius_m=120&limit=5"
]) {
const first = await app.inject({ method: "GET", url });
const second = await app.inject({ method: "GET", url });
assert.equal(first.statusCode, 502, url);
assert.equal(second.statusCode, 502, `${url} repeat`);
assert.equal(first.json().error, "upstream_error", url);
assert.equal(first.json().message, "Seoul Bike upstream request failed.", url);
assert.equal(first.json().proxy.cache.hit, false, url);
assert.doesNotMatch(first.body, new RegExp(secret), url);
assert.doesNotMatch(second.body, new RegExp(secret), `${url} repeat`);
assert.doesNotMatch(first.body, /openapi\.seoul\.go\.kr/i, url);
}
assert.equal(fetchCalls, 6, "sanitized upstream failures must not be cached");
});
test("seoul bike nearby endpoint validates coordinates", async (t) => {
const app = buildServer({ env: { SEOUL_OPEN_API_KEY: "seoul-key" } });
t.after(async () => {
await app.close();
});
const response = await app.inject({ method: "GET", url: "/v1/seoul-bike/nearby?lat=not-a-number&lon=126.9763" });
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
for (const url of [
"/v1/seoul-bike/nearby?lat=&lon=126.9763",
"/v1/seoul-bike/nearby?lat=37.5717&lon="
]) {
const blankResponse = await app.inject({ method: "GET", url });
assert.equal(blankResponse.statusCode, 400, url);
assert.equal(blankResponse.json().error, "bad_request", url);
}
});
test("seoul bike endpoints reject partially numeric integer query params", async (t) => {
const app = buildServer({ env: { SEOUL_OPEN_API_KEY: "seoul-key" } });
t.after(async () => {
await app.close();
});
for (const url of [
"/v1/seoul-bike/realtime?startIndex=10abc&endIndex=20",
"/v1/seoul-bike/stations?startIndex=1&endIndex=1.5",
"/v1/seoul-bike/nearby?lat=37.5717&lon=126.9763&radius_m=120m",
"/v1/seoul-bike/nearby?lat=37.5717&lon=126.9763&limit=5.5"
]) {
const response = await app.inject({ method: "GET", url });
assert.equal(response.statusCode, 400, url);
assert.equal(response.json().error, "bad_request", url);
}
});
test("korea weather endpoint caches successful upstream responses for normalized coordinate queries", async (t) => { test("korea weather endpoint caches successful upstream responses for normalized coordinate queries", async (t) => {
const originalFetch = global.fetch; const originalFetch = global.fetch;
let fetchCalls = 0; let fetchCalls = 0;

151
scripts/test_seoul_bike.py Normal file
View file

@ -0,0 +1,151 @@
import contextlib
import importlib.util
import io
import json
import pathlib
import unittest
from unittest import mock
ROOT = pathlib.Path(__file__).resolve().parents[1]
MODULE_PATH = ROOT / "seoul-bike" / "scripts" / "seoul_bike.py"
spec = importlib.util.spec_from_file_location("seoul_bike", MODULE_PATH)
seoul_bike = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(seoul_bike)
NEARBY_PAYLOAD = {
"query": {"latitude": 37.5717, "longitude": 126.9763, "radius_m": 500, "limit": 2},
"count": 2,
"items": [
{
"station_id": "ST-101",
"station_name": "101. 광화문역 1번출구 앞",
"available_bikes": 4,
"empty_docks": 11,
"rack_total_count": 15,
"shared_percent": 27,
"distance_m": 0,
"latitude": 37.5717,
"longitude": 126.9763,
},
{
"station_id": "ST-102",
"station_name": "102. 세종대로 앞",
"available_bikes": 0,
"empty_docks": 12,
"rack_total_count": 12,
"shared_percent": 0,
"distance_m": 80,
"latitude": 37.5720,
"longitude": 126.9770,
},
],
"proxy": {"requested_at": "2026-05-21T06:10:00.000Z"},
}
REALTIME_PAYLOAD = {
"rentBikeStatus": {
"row": [
{
"stationId": "ST-101",
"stationName": "101. 광화문역 1번출구 앞",
"rackTotCnt": "15",
"parkingBikeTotCnt": "4",
"shared": "27",
"stationLatitude": "37.5717",
"stationLongitude": "126.9763",
}
]
},
"proxy": {"requested_at": "2026-05-21T06:10:00.000Z"},
}
class SeoulBikePayloadTest(unittest.TestCase):
def test_summarize_nearby_includes_bikes_docks_distance_and_timestamp(self):
lines = seoul_bike.format_nearby(NEARBY_PAYLOAD)
joined = "\n".join(lines)
self.assertIn("101. 광화문역 1번출구 앞", joined)
self.assertIn("대여 가능 4대", joined)
self.assertIn("빈 거치대 11개", joined)
self.assertIn("0m", joined)
self.assertIn("조회 시각: 2026-05-21T06:10:00.000Z", joined)
def test_search_realtime_filters_station_names_and_reports_empty_docks(self):
matches = seoul_bike.filter_realtime_rows(REALTIME_PAYLOAD, "광화문", limit=5)
self.assertEqual(len(matches), 1)
self.assertEqual(matches[0]["station_id"], "ST-101")
self.assertEqual(matches[0]["available_bikes"], 4)
self.assertEqual(matches[0]["empty_docks"], 11)
def test_search_fetches_all_realtime_pages_before_filtering(self):
first = {
"rentBikeStatus": {
"list_total_count": 2,
"row": [{"stationId": "ST-001", "stationName": "001. 첫 페이지", "rackTotCnt": "1", "parkingBikeTotCnt": "1"}],
}
}
second = {
"rentBikeStatus": {
"list_total_count": 2,
"row": [{"stationId": "ST-999", "stationName": "999. 마지막 광화문", "rackTotCnt": "3", "parkingBikeTotCnt": "2"}],
}
}
with mock.patch.object(seoul_bike, "fetch_json", side_effect=[first, second]) as fetch_json:
rows = seoul_bike.fetch_realtime_pages(1, 1)
self.assertEqual([row["stationId"] for row in rows], ["ST-001", "ST-999"])
self.assertEqual(fetch_json.call_count, 2)
def test_cli_search_prints_realtime_lookup_timestamp(self):
payload = {
"rentBikeStatus": {
"list_total_count": 1,
"row": [
{
"stationId": "ST-101",
"stationName": "101. 광화문역 1번출구 앞",
"rackTotCnt": "15",
"parkingBikeTotCnt": "4",
}
],
},
"proxy": {"requested_at": "2026-05-21T06:10:00.000Z"},
}
with mock.patch.object(seoul_bike, "fetch_json", return_value=payload):
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
exit_code = seoul_bike.main(["search", "광화문"])
self.assertEqual(exit_code, 0)
self.assertIn("조회 시각: 2026-05-21T06:10:00.000Z", stdout.getvalue())
def test_cli_nearby_prints_json_when_requested(self):
with mock.patch.object(seoul_bike, "fetch_json", return_value=NEARBY_PAYLOAD):
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
exit_code = seoul_bike.main([
"nearby",
"--lat",
"37.5717",
"--lon",
"126.9763",
"--json",
])
self.assertEqual(exit_code, 0)
body = json.loads(stdout.getvalue())
self.assertEqual(body["items"][0]["station_id"], "ST-101")
def test_proxy_base_url_defaults_to_hosted_proxy(self):
with mock.patch.dict(seoul_bike.os.environ, {}, clear=True):
self.assertEqual(seoul_bike.get_proxy_base_url(), "https://k-skill-proxy.nomadamas.org")
if __name__ == "__main__":
unittest.main()

93
seoul-bike/SKILL.md Normal file
View file

@ -0,0 +1,93 @@
---
name: seoul-bike
description: 서울 따릉이 실시간 대여소의 대여 가능 자전거와 빈 거치대를 좌표 주변 또는 대여소 이름으로 조회한다.
license: MIT
metadata:
category: transit
locale: ko-KR
phase: v1
---
# Seoul Bike (따릉이)
## What this skill does
서울 열린데이터 광장의 따릉이 실시간 대여정보를 `k-skill-proxy` 경유로 조회해 대여 가능 자전거 수와 빈 거치대 수를 요약한다.
## When to use
- "지금 여기서 따릉이 빌릴 수 있어?"
- "광화문 근처 빈 거치대 있어?"
- "강남역 따릉이 대여소에 자전거 몇 대 남았어?"
## Prerequisites
- Python 3 표준 라이브러리만 사용한다.
- optional: `KSKILL_PROXY_BASE_URL` (self-host·별도 프록시를 쓸 때만 설정. 비우면 기본 hosted `https://k-skill-proxy.nomadamas.org` 를 사용한다.)
## Required environment variables
없음. 사용자가 개인 서울 열린데이터 광장 OpenAPI key를 직접 발급할 필요는 없다. `/v1/seoul-bike/*` routes는 기본 hosted proxy에서 호출하고, upstream key는 proxy 서버 쪽에만 보관한다.
## Single entrypoint
```bash
python3 "$SKILL_DIR/scripts/seoul_bike.py" <subcommand> [args]
```
첫 사용 시 `Bash(python3 *seoul_bike.py:*)` 패턴 한 번만 승인하면 이후 호출은 모두 자동 허용된다.
## Subcommands
| 명령 | 설명 |
|---|---|
| `nearby --lat LAT --lon LON [--radius-m 500] [--limit 10] [--json]` | 좌표 주변 실시간 대여소 조회 |
| `search <키워드> [--limit 10] [--json]` | 대여소 이름에 키워드가 포함된 실시간 상태 검색 |
| `realtime [--start-index 1 --end-index 1000]` | 실시간 대여정보 원문 JSON 페이지 조회 |
## Workflow
### 1. 현재 위치 주변 대여소 조회
```bash
python3 "$SKILL_DIR/scripts/seoul_bike.py" nearby --lat 37.5717 --lon 126.9763 --radius-m 500
```
요약 항목:
- 대여소명
- 대여 가능 자전거 수 (`parkingBikeTotCnt`)
- 빈 거치대 수 (`rackTotCnt - parkingBikeTotCnt`)
- 거리(m)
- 조회 시각(`proxy.requested_at`)
### 2. 대여소 이름 검색
```bash
python3 "$SKILL_DIR/scripts/seoul_bike.py" search "광화문" --limit 5
```
### 3. Proxy endpoints
- `GET /v1/seoul-bike/realtime?startIndex=1&endIndex=1000` → 서울 `bikeList` 실시간 대여정보
- `GET /v1/seoul-bike/stations?startIndex=1&endIndex=1000` → 서울 `tbCycleStationInfo` 대여소 마스터 정보
- `GET /v1/seoul-bike/nearby?lat=37.5717&lon=126.9763&radius_m=500&limit=10` → proxy-side 주변 대여소 필터링
## Done when
- 대여 가능 자전거 수와 빈 거치대 수가 정리되어 있다.
- live data 기준 조회 시각이 명시되어 있다.
- upstream key가 클라이언트에 노출되지 않았다.
## Failure modes
- proxy upstream key 미설정 (`SEOUL_OPEN_API_KEY` 없음)
- 서울 열린데이터 광장 quota 초과
- 실시간 API가 빈 행 또는 일시 오류를 반환
- 좌표가 없거나 반경 안에 대여소가 없음
## Notes
- 실시간 데이터는 계속 변하므로 답변에 조회 시각을 함께 적는다.
- 예약/대여 자동화는 하지 않는다. 조회 전용 스킬이다.
- proxy 운영/환경변수 설정은 `docs/features/k-skill-proxy.md` 를 참고한다.

235
seoul-bike/scripts/seoul_bike.py Executable file
View file

@ -0,0 +1,235 @@
#!/usr/bin/env python3
"""Single-entrypoint CLI for the seoul-bike skill.
Subcommands:
nearby --lat LAT --lon LON find realtime Seoul Bike stations near coordinates
search KEYWORD search station names in realtime availability page(s)
realtime fetch raw realtime station availability
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
for _stream in (sys.stdout, sys.stderr):
reconfigure = getattr(_stream, "reconfigure", None)
if reconfigure is not None:
try:
reconfigure(encoding="utf-8")
except (OSError, ValueError):
pass
TIMEOUT_SEC = 15
PROXY_BASE_URL_NAME = "KSKILL_PROXY_BASE_URL"
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
def get_proxy_base_url() -> str:
value = os.environ.get(PROXY_BASE_URL_NAME)
if value and value.strip() and value.strip() != "replace-me":
return value.strip().rstrip("/")
return DEFAULT_PROXY_BASE_URL
def fetch_json(path: str, params: dict[str, Any]) -> dict[str, Any]:
query = urllib.parse.urlencode(params)
url = f"{get_proxy_base_url()}{path}?{query}"
req = urllib.request.Request(url, headers={"User-Agent": "k-skill/seoul-bike"})
with urllib.request.urlopen(req, timeout=TIMEOUT_SEC) as resp:
raw = resp.read().decode("utf-8")
return json.loads(raw)
def _to_int(value: Any) -> int | None:
if value in (None, ""):
return None
try:
return int(float(value))
except (TypeError, ValueError):
return None
def normalize_realtime_row(row: dict[str, Any]) -> dict[str, Any]:
rack_total = _to_int(row.get("rackTotCnt") or row.get("rack_total_count"))
available = _to_int(row.get("parkingBikeTotCnt") or row.get("available_bikes"))
empty_docks = None if rack_total is None or available is None else max(0, rack_total - available)
return {
"station_id": row.get("stationId") or row.get("station_id"),
"station_name": row.get("stationName") or row.get("station_name"),
"rack_total_count": rack_total,
"available_bikes": available,
"empty_docks": empty_docks,
"shared_percent": _to_int(row.get("shared") or row.get("shared_percent")),
"latitude": row.get("stationLatitude") or row.get("latitude"),
"longitude": row.get("stationLongitude") or row.get("longitude"),
}
def realtime_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
status = payload.get("rentBikeStatus") or {}
rows = status.get("row") or []
return rows if isinstance(rows, list) else []
def filter_realtime_rows(payload: dict[str, Any], keyword: str, limit: int) -> list[dict[str, Any]]:
normalized_keyword = keyword.strip().lower()
matches: list[dict[str, Any]] = []
for row in realtime_rows(payload):
station_name = str(row.get("stationName") or row.get("station_name") or "")
if normalized_keyword in station_name.lower():
matches.append(normalize_realtime_row(row))
if len(matches) >= limit:
break
return matches
def format_station(item: dict[str, Any]) -> str:
distance = item.get("distance_m")
distance_text = f", 거리 {distance}m" if distance is not None else ""
bikes = item.get("available_bikes")
docks = item.get("empty_docks")
bikes_text = "알 수 없음" if bikes is None else f"{bikes}"
docks_text = "알 수 없음" if docks is None else f"{docks}"
return f"- {item.get('station_name')}: 대여 가능 {bikes_text}, 빈 거치대 {docks_text}{distance_text}"
def format_nearby(payload: dict[str, Any]) -> list[str]:
query = payload.get("query") or {}
lines = [
f"따릉이 주변 대여소 {payload.get('count', 0)}",
f"기준 좌표: {query.get('latitude')}, {query.get('longitude')} / 반경 {query.get('radius_m')}m",
]
for item in payload.get("items") or []:
lines.append(format_station(item))
requested_at = (payload.get("proxy") or {}).get("requested_at")
if requested_at:
lines.append(f"조회 시각: {requested_at}")
return lines
def cmd_nearby(args: argparse.Namespace) -> int:
payload = fetch_json(
"/v1/seoul-bike/nearby",
{"lat": args.lat, "lon": args.lon, "radius_m": args.radius_m, "limit": args.limit},
)
if args.json:
json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
else:
print("\n".join(format_nearby(payload)))
return 0
def fetch_realtime_payload(start_index: int = 1, end_index: int = 1000) -> dict[str, Any]:
rows: list[dict[str, Any]] = []
current_start = start_index
page_size = max(1, end_index - start_index + 1)
requested_at = None
while True:
current_end = current_start + page_size - 1
payload = fetch_json(
"/v1/seoul-bike/realtime",
{"startIndex": current_start, "endIndex": current_end},
)
if requested_at is None:
requested_at = (payload.get("proxy") or {}).get("requested_at")
page_rows = realtime_rows(payload)
rows.extend(page_rows)
total_count = _to_int((payload.get("rentBikeStatus") or {}).get("list_total_count"))
if total_count is None or current_end >= total_count or not page_rows:
break
current_start = current_end + 1
return {
"rentBikeStatus": {"row": rows},
"proxy": {"requested_at": requested_at},
}
def fetch_realtime_pages(start_index: int = 1, end_index: int = 1000) -> list[dict[str, Any]]:
return realtime_rows(fetch_realtime_payload(start_index, end_index))
def cmd_search(args: argparse.Namespace) -> int:
payload = fetch_realtime_payload(args.start_index, args.end_index)
matches = filter_realtime_rows(payload, args.keyword, args.limit)
if args.json:
json.dump({"keyword": args.keyword, "count": len(matches), "items": matches, "proxy": payload.get("proxy")}, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
else:
if not matches:
print(f"'{args.keyword}'와 일치하는 따릉이 대여소가 없습니다.", file=sys.stderr)
return 1
print(f"따릉이 대여소 검색: {args.keyword}")
for item in matches:
print(format_station(item))
requested_at = (payload.get("proxy") or {}).get("requested_at")
if requested_at:
print(f"조회 시각: {requested_at}")
return 0
def cmd_realtime(args: argparse.Namespace) -> int:
payload = fetch_json(
"/v1/seoul-bike/realtime",
{"startIndex": args.start_index, "endIndex": args.end_index},
)
json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="서울 따릉이 실시간 대여소 조회")
sub = parser.add_subparsers(dest="command", required=True)
nearby = sub.add_parser("nearby", help="좌표 주변 대여소 조회")
nearby.add_argument("--lat", required=True, type=float)
nearby.add_argument("--lon", required=True, type=float)
nearby.add_argument("--radius-m", type=int, default=500)
nearby.add_argument("--limit", type=int, default=10)
nearby.add_argument("--json", action="store_true")
nearby.set_defaults(func=cmd_nearby)
search = sub.add_parser("search", help="실시간 대여소 이름 검색")
search.add_argument("keyword")
search.add_argument("--start-index", type=int, default=1)
search.add_argument("--end-index", type=int, default=1000, help="page size end index for the first realtime page; search continues through all pages")
search.add_argument("--limit", type=int, default=10)
search.add_argument("--json", action="store_true")
search.set_defaults(func=cmd_search)
realtime = sub.add_parser("realtime", help="실시간 대여소 원문 JSON 조회")
realtime.add_argument("--start-index", type=int, default=1)
realtime.add_argument("--end-index", type=int, default=1000)
realtime.set_defaults(func=cmd_realtime)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
return args.func(args)
except urllib.error.HTTPError as exc:
print(f"API HTTP 오류: {exc.code} {exc.reason}", file=sys.stderr)
return 1
except urllib.error.URLError as exc:
print(f"API 연결 실패: {exc.reason}", file=sys.stderr)
return 1
except json.JSONDecodeError as exc:
print(f"API 응답 JSON 파싱 실패: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())