mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Add seoul-density skill and proxy route for Seoul realtime hotspot crowd levels
This commit is contained in:
parent
ca9a7df933
commit
315dbbb66b
15 changed files with 894 additions and 1 deletions
5
.changeset/seoul-density.md
Normal file
5
.changeset/seoul-density.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"k-skill-proxy": minor
|
||||
---
|
||||
|
||||
Add `/v1/seoul-density/citydata` route that proxies the Seoul Open Data realtime hotspot crowd-level API (`citydata_ppltn`) using the server-side `SEOUL_OPEN_API_KEY`.
|
||||
|
|
@ -29,6 +29,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
| 자연휴양림 빈 객실 조회 | `foresttrip-vacancy` | 공식 숲나들e 자연휴양림 예약 가능 객실 조회 자동화 (예약/결제 제외) | 필요 | [자연휴양림 빈 객실 조회 가이드](docs/features/foresttrip-vacancy.md) |
|
||||
| 카카오톡 Mac CLI | `kakaotalk-mac` | macOS에서 카카오톡 대화 조회, 검색, 메시지 전송 | 불필요 | [카카오톡 Mac CLI 가이드](docs/features/kakaotalk-mac.md) |
|
||||
| 서울 지하철 도착정보 조회 | `seoul-subway-arrival` | 서울 지하철 역 기준 실시간 도착 예정 열차 확인 | 불필요 | [서울 지하철 도착정보 가이드](docs/features/seoul-subway-arrival.md) |
|
||||
| 서울 실시간 혼잡도 조회 | `seoul-density` | 서울 주요 121개 핫스팟의 실시간 혼잡도 단계와 추정 인구 조회 | 불필요 | [서울 실시간 혼잡도 가이드](docs/features/seoul-density.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) |
|
||||
| 긱뉴스 조회 | `geeknews-search` | GeekNews 공개 RSS/Atom 피드 기반 최신 글 목록, 검색, 상세 확인 | 불필요 | [긱뉴스 조회 가이드](docs/features/geeknews-search.md) |
|
||||
|
|
@ -137,6 +138,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
- [자연휴양림 빈 객실 조회](docs/features/foresttrip-vacancy.md)
|
||||
- [카카오톡 Mac CLI](docs/features/kakaotalk-mac.md)
|
||||
- [서울 지하철 도착정보 조회](docs/features/seoul-subway-arrival.md)
|
||||
- [서울 실시간 혼잡도 조회](docs/features/seoul-density.md)
|
||||
- [한국 대중교통 길찾기 가이드](docs/features/korean-transit-route.md)
|
||||
- [지하철 분실물 조회 가이드](docs/features/subway-lost-property.md)
|
||||
- [긱뉴스 조회 가이드](docs/features/geeknews-search.md)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ client/skill -> k-skill-proxy -> upstream public API
|
|||
- `GET /v1/fine-dust/report`
|
||||
- `GET /v1/korea-weather/forecast`
|
||||
- `GET /v1/seoul-subway/arrival`
|
||||
- `GET /v1/seoul-density/citydata` (서울 실시간 도시데이터 핫스팟 혼잡도/추정 인구, `SEOUL_OPEN_API_KEY`)
|
||||
- `GET /v1/han-river/water-level`
|
||||
- `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`)
|
||||
|
|
@ -120,6 +121,14 @@ curl -fsS --get "${BASE}/v1/seoul-subway/arrival" \
|
|||
--data-urlencode 'stationName=강남'
|
||||
```
|
||||
|
||||
서울 실시간 혼잡도 endpoint:
|
||||
|
||||
```bash
|
||||
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
|
||||
curl -fsS --get "${BASE}/v1/seoul-density/citydata" \
|
||||
--data-urlencode 'area=강남역'
|
||||
```
|
||||
|
||||
한국 날씨 endpoint:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
88
docs/features/seoul-density.md
Normal file
88
docs/features/seoul-density.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# 서울 실시간 혼잡도 조회 가이드
|
||||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
- 서울 주요 121개 핫스팟의 실시간 혼잡도 단계(여유 / 보통 / 약간 붐빔 / 붐빔) 확인
|
||||
- KT·SKT 통신 신호 기반 추정 인구 범위(`AREA_PPLTN_MIN ~ AREA_PPLTN_MAX`) 확인
|
||||
- 기준 시각(`PPLTN_TIME`)과 혼잡도 메시지(`AREA_CONGEST_MSG`) 같이 확인
|
||||
- 별도 사용자 `SEOUL_OPEN_API_KEY` 없이 `k-skill-proxy` 로 조회
|
||||
|
||||
## 먼저 필요한 것
|
||||
|
||||
- [공통 설정 가이드](../setup.md) 확인
|
||||
|
||||
## 기본 경로
|
||||
|
||||
기본적으로 `https://k-skill-proxy.nomadamas.org/v1/seoul-density/citydata` 로 요청한다.
|
||||
|
||||
사용자는 별도 서울 열린데이터 광장 OpenAPI key 를 직접 발급받을 필요는 없다. upstream key 는 proxy 서버에서만 `SEOUL_OPEN_API_KEY` 로 관리한다.
|
||||
|
||||
`KSKILL_PROXY_BASE_URL` 환경변수가 있으면 그 값을 사용하고, 비우면 기본 hosted `https://k-skill-proxy.nomadamas.org` 를 쓴다.
|
||||
|
||||
## 입력값
|
||||
|
||||
- `area` — 지원 장소명 (예: `강남역`, `홍대 관광특구`, `여의도한강공원`)
|
||||
|
||||
지원 장소 전체 목록은 `seoul-density/SKILL.md` 의 `AREAS` 카테고리 또는 다음 명령으로 확인한다:
|
||||
|
||||
```bash
|
||||
python3 seoul-density/scripts/seoul_density.py list
|
||||
```
|
||||
|
||||
## 기본 흐름
|
||||
|
||||
1. client/skill 은 기본 hosted path 또는 `KSKILL_PROXY_BASE_URL` 아래 `/v1/seoul-density/citydata` endpoint 를 호출한다.
|
||||
2. proxy 는 서울 열린데이터 광장 `citydata_ppltn/1/1/{area}` 를 `SEOUL_OPEN_API_KEY` 와 함께 호출한다.
|
||||
3. 응답을 그대로 돌려주며, `proxy.cache.hit` 메타데이터를 추가한다.
|
||||
|
||||
## 예시
|
||||
|
||||
```bash
|
||||
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
|
||||
curl -fsS --get "${BASE}/v1/seoul-density/citydata" \
|
||||
--data-urlencode 'area=강남역'
|
||||
```
|
||||
|
||||
스킬 CLI 사용 예시:
|
||||
|
||||
```bash
|
||||
python3 seoul-density/scripts/seoul_density.py query "강남역"
|
||||
```
|
||||
|
||||
예상 응답 (요약):
|
||||
|
||||
```json
|
||||
{
|
||||
"SeoulRtd.citydata_ppltn": [
|
||||
{
|
||||
"AREA_NM": "강남역",
|
||||
"AREA_CONGEST_LVL": "약간 붐빔",
|
||||
"AREA_PPLTN_MIN": "24000",
|
||||
"AREA_PPLTN_MAX": "26000",
|
||||
"PPLTN_TIME": "2026-05-14 09:30",
|
||||
"AREA_CONGEST_MSG": "사람이 몰려있을 수 있어요"
|
||||
}
|
||||
],
|
||||
"RESULT": { "RESULT.CODE": "INFO-000" }
|
||||
}
|
||||
```
|
||||
|
||||
## fallback / 대체 흐름
|
||||
|
||||
- `KSKILL_PROXY_BASE_URL` 을 별도로 넣으면 해당 proxy 를 우선 사용한다.
|
||||
- 기본 hosted path 는 `https://k-skill-proxy.nomadamas.org/v1/seoul-density/citydata` 이다.
|
||||
- self-host 운영자는 서버 쪽에만 `SEOUL_OPEN_API_KEY` 를 넣는다 (사용자 쪽에는 키가 필요 없다).
|
||||
|
||||
## 주의할 점
|
||||
|
||||
- 인구 수치는 실제값이 아닌 **추계치** (KT·SKT 통신 신호 데이터 기반).
|
||||
- 데이터는 호출 시점 기준 **약 15분 전** 값이며 5분 주기로 갱신된다.
|
||||
- 새벽 01~05시는 실시간 데이터가 제공되지 않을 수 있다.
|
||||
- 일일 호출 할당량 초과 시 다음 날 재시도해야 한다.
|
||||
- 지원하지 않는 장소명을 넣으면 빈 응답이 돌아오므로 스킬의 `match` 서브커맨드로 후보를 먼저 확인한다.
|
||||
|
||||
## 참고 표면
|
||||
|
||||
- 공식 API 안내: `https://data.seoul.go.kr/dataList/OA-21778/A/1/datasetView.do`
|
||||
- 서울 열린데이터 광장: `https://data.seoul.go.kr`
|
||||
- proxy 운영 안내: [k-skill 프록시 서버 가이드](k-skill-proxy.md)
|
||||
|
|
@ -118,6 +118,7 @@ npx --yes skills add <owner/repo> \
|
|||
--skill korean-patent-search \
|
||||
--skill hipass-receipt \
|
||||
--skill seoul-subway-arrival \
|
||||
--skill seoul-density \
|
||||
--skill subway-lost-property \
|
||||
--skill geeknews-search \
|
||||
--skill korea-weather \
|
||||
|
|
@ -361,6 +362,7 @@ node scripts/korean_character_count.js --text $'첫 줄\n둘째 줄🙂' --profi
|
|||
- `srt-booking`
|
||||
- `ktx-booking`
|
||||
- `seoul-subway-arrival`
|
||||
- `seoul-density`
|
||||
- `korea-weather`
|
||||
- `fine-dust-location`
|
||||
- `korean-law-search`
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ KAKAO_REST_API_KEY=replace-me
|
|||
KSKILL_PROXY_BASE_URL=
|
||||
```
|
||||
|
||||
서울 지하철 도착정보와 한국 날씨 조회는 `KSKILL_PROXY_BASE_URL` 이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, KOSIS 일반 조회, Kakao Local geocoding, 의약품 안전 체크, 식품 안전 체크도 기본 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, 의약품 안전 체크, 식품 안전 체크도 기본 hosted proxy를 쓴다. 생활쓰레기 배출정보는 `k-skill-proxy`의 `/v1/household-waste/info` 라우트를 거쳐 `serviceKey`만 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다.
|
||||
|
||||
## Missing secret handling policy
|
||||
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ bash scripts/check-setup.sh
|
|||
| 한국 주식 정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KRX_API_KEY`) |
|
||||
| 근처 가장 싼 주유소 찾기 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
|
||||
| 서울 지하철 도착정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `SEOUL_OPEN_API_KEY`) |
|
||||
| 서울 실시간 혼잡도 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `SEOUL_OPEN_API_KEY`) |
|
||||
| 한국 날씨 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KMA_OPEN_API_KEY`) |
|
||||
| 사용자 위치 미세먼지 조회 | `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY` |
|
||||
| 한강 수위 정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
|
||||
|
|
@ -103,6 +104,7 @@ bash scripts/check-setup.sh
|
|||
- [시외버스 예매 가이드](features/intercity-bus-booking.md)
|
||||
- [자연휴양림 빈 객실 조회 가이드](features/foresttrip-vacancy.md)
|
||||
- [서울 지하철 도착정보 가이드](features/seoul-subway-arrival.md)
|
||||
- [서울 실시간 혼잡도 가이드](features/seoul-density.md)
|
||||
- [한국 날씨 조회 가이드](features/korea-weather.md)
|
||||
- [사용자 위치 미세먼지 조회 가이드](features/fine-dust-location.md)
|
||||
- [한강 수위 정보 가이드](features/han-river-water-level.md)
|
||||
|
|
|
|||
|
|
@ -160,6 +160,7 @@
|
|||
- 공중화장실정보 전국 CSV: https://file.localdata.go.kr/file/download/public_restroom_info/info
|
||||
- 공중화장실정보 지역별 CSV: https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=<시도코드>
|
||||
- 서울특별시 지하철 실시간 도착정보: https://www.data.go.kr/data/15058052/openapi.do
|
||||
- 서울 실시간 도시데이터(`citydata_ppltn`): https://data.seoul.go.kr/dataList/OA-21778/A/1/datasetView.do
|
||||
- 경찰청 LOST112 습득물 목록: https://www.lost112.go.kr/find/findList.do
|
||||
- 서울교통공사 유실물센터: https://www.seoulmetro.co.kr/kr/page.do?menuIdx=541
|
||||
- GeekNews public RSS/Atom feed: https://feeds.feedburner.com/geeknews-feed
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
|
|||
- 식품 안전 체크: 사용자 시크릿 불필요 (`DATA_GO_KR_API_KEY`와 선택적 `FOODSAFETYKOREA_API_KEY`는 proxy 서버만)
|
||||
- 근처 가장 싼 주유소 찾기: 사용자 시크릿 불필요 (기본 hosted proxy 사용)
|
||||
- 서울 지하철: 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `SEOUL_OPEN_API_KEY`)
|
||||
- 서울 실시간 혼잡도: 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `SEOUL_OPEN_API_KEY`)
|
||||
- 한국 날씨: 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KMA_OPEN_API_KEY`)
|
||||
- 사용자 위치 미세먼지 조회: `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY`
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
- `GET /v1/fine-dust/report`
|
||||
- `GET /v1/korea-weather/forecast`
|
||||
- `GET /v1/seoul-subway/arrival`
|
||||
- `GET /v1/seoul-density/citydata` — 서울 실시간 도시데이터(`citydata_ppltn`) 핫스팟 혼잡도/추정 인구(`SEOUL_OPEN_API_KEY`)
|
||||
- `GET /v1/han-river/water-level`
|
||||
- `GET /v1/household-waste/info` — 생활쓰레기 배출정보(`DATA_GO_KR_API_KEY`; `pageNo=1`, `numOfRows=100` 필수)
|
||||
- `GET /v1/parking-lots/search` — 전국주차장정보표준데이터 기반 근처 공영주차장 검색(`DATA_GO_KR_API_KEY`)
|
||||
|
|
@ -79,6 +80,13 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/seoul-subway/arrival' \
|
|||
--data-urlencode 'stationName=강남'
|
||||
```
|
||||
|
||||
서울 실시간 혼잡도 예시 (`SEOUL_OPEN_API_KEY` 필요):
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'http://127.0.0.1:4020/v1/seoul-density/citydata' \
|
||||
--data-urlencode 'area=강남역'
|
||||
```
|
||||
|
||||
한국 날씨 예시:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ const DATA4LIBRARY_UPSTREAM_BASE_URL = "https://data4library.kr/api";
|
|||
const KAKAO_LOCAL_API_BASE_URL = "https://dapi.kakao.com/v2/local";
|
||||
const KOSIS_OPEN_API_BASE_URL = "https://kosis.kr/openapi";
|
||||
const SEOUL_OPEN_API_BASE_URL = "http://swopenapi.seoul.go.kr";
|
||||
const SEOUL_CITYDATA_BASE_URL = "http://openapi.seoul.go.kr:8088";
|
||||
const KMA_FORECAST_BASE_TIMES = ["0200", "0500", "0800", "1100", "1400", "1700", "2000", "2300"];
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
const KMA_FORECAST_READY_MINUTE = 10;
|
||||
|
|
@ -480,6 +481,14 @@ function normalizeSeoulSubwayQuery(query) {
|
|||
};
|
||||
}
|
||||
|
||||
function normalizeSeoulCityDataQuery(query) {
|
||||
const area = trimOrNull(query.area ?? query.areaNm ?? query.area_nm);
|
||||
if (!area) {
|
||||
throw new Error("Provide area.");
|
||||
}
|
||||
return { area };
|
||||
}
|
||||
|
||||
function normalizeKosisSearchQuery(query) {
|
||||
const searchNm = trimOrNull(query.searchNm ?? query.search_nm ?? query.query ?? query.q);
|
||||
if (!searchNm) {
|
||||
|
|
@ -1049,6 +1058,38 @@ async function proxySeoulSubwayRequest({
|
|||
};
|
||||
}
|
||||
|
||||
async function proxySeoulCityDataRequest({
|
||||
area,
|
||||
apiKey,
|
||||
fetchImpl = global.fetch
|
||||
}) {
|
||||
if (!apiKey) {
|
||||
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."
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
const encodedArea = encodeURIComponent(area);
|
||||
const url = new URL(
|
||||
`${SEOUL_CITYDATA_BASE_URL}/${apiKey}/json/citydata_ppltn/1/1/${encodedArea}`
|
||||
);
|
||||
|
||||
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 proxyKmaWeatherRequest({
|
||||
baseDate,
|
||||
baseTime,
|
||||
|
|
@ -1706,6 +1747,66 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
return payload;
|
||||
});
|
||||
|
||||
app.get("/v1/seoul-density/citydata", async (request, reply) => {
|
||||
let normalized;
|
||||
|
||||
try {
|
||||
normalized = normalizeSeoulCityDataQuery(request.query || {});
|
||||
} catch (error) {
|
||||
reply.code(400);
|
||||
return {
|
||||
error: "bad_request",
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
|
||||
const cacheKey = makeCacheKey({
|
||||
route: "seoul-density-citydata",
|
||||
...normalized
|
||||
});
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
return {
|
||||
...cached,
|
||||
proxy: {
|
||||
...cached.proxy,
|
||||
cache: {
|
||||
hit: true,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const upstream = await proxySeoulCityDataRequest({
|
||||
...normalized,
|
||||
apiKey: config.seoulOpenApiKey
|
||||
});
|
||||
|
||||
reply.code(upstream.statusCode);
|
||||
reply.header("content-type", upstream.contentType);
|
||||
|
||||
if (!upstream.contentType.includes("json")) {
|
||||
return upstream.body;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(upstream.body);
|
||||
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;
|
||||
});
|
||||
|
||||
async function handleKosisRoute({ operation, normalize, cacheRoute, request, reply }) {
|
||||
let normalized;
|
||||
|
||||
|
|
@ -3853,6 +3954,7 @@ module.exports = {
|
|||
normalizeParkingLotSearchQuery,
|
||||
normalizeRealEstateQuery,
|
||||
normalizeRegionCodeQuery,
|
||||
normalizeSeoulCityDataQuery,
|
||||
normalizeSeoulSubwayQuery,
|
||||
proxyAirKoreaRequest,
|
||||
proxyData4LibraryRequest,
|
||||
|
|
@ -3864,6 +3966,7 @@ module.exports = {
|
|||
proxyKosisRequest,
|
||||
fetchNaverShoppingSearch,
|
||||
proxyOpinetRequest,
|
||||
proxySeoulCityDataRequest,
|
||||
proxySeoulSubwayRequest,
|
||||
resolveLatestKmaForecastBase,
|
||||
startServer
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const {
|
|||
proxyKakaoLocalRequest,
|
||||
proxyKosisRequest,
|
||||
proxyKmaWeatherRequest,
|
||||
proxySeoulCityDataRequest,
|
||||
proxySeoulSubwayRequest
|
||||
} = require("../src/server");
|
||||
const { resolveEducationOfficeFromNaturalLanguage } = require("../src/neis-office-codes");
|
||||
|
|
@ -1323,6 +1324,145 @@ test("proxySeoulSubwayRequest injects API key and preserves index/station params
|
|||
assert.match(calledUrl, /\/api\/subway\/test-seoul-key\/json\/realtimeStationArrival\/2\/5\/%EA%B0%95%EB%82%A8$/);
|
||||
});
|
||||
|
||||
test("seoul density endpoint caches successful upstream responses for normalized area queries", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
let fetchCalls = 0;
|
||||
global.fetch = async () => {
|
||||
fetchCalls += 1;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
"SeoulRtd.citydata_ppltn": [
|
||||
{
|
||||
AREA_NM: "강남역",
|
||||
AREA_CONGEST_LVL: "약간 붐빔",
|
||||
AREA_PPLTN_MIN: "24000",
|
||||
AREA_PPLTN_MAX: "26000",
|
||||
PPLTN_TIME: "2026-05-14 09:30",
|
||||
AREA_CONGEST_MSG: "사람이 몰려있을 수 있어요"
|
||||
}
|
||||
],
|
||||
RESULT: { "RESULT.CODE": "INFO-000", "RESULT.MESSAGE": "정상 처리되었습니다." }
|
||||
}),
|
||||
{
|
||||
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-density/citydata?area=%EA%B0%95%EB%82%A8%EC%97%AD"
|
||||
});
|
||||
const second = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/seoul-density/citydata?area=%EA%B0%95%EB%82%A8%EC%97%AD"
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test("seoul density endpoint stays publicly callable without proxy auth", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
let calledUrl;
|
||||
global.fetch = async (url) => {
|
||||
calledUrl = String(url);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
"SeoulRtd.citydata_ppltn": [{ AREA_NM: "강남역" }],
|
||||
RESULT: { "RESULT.CODE": "INFO-000" }
|
||||
}),
|
||||
{
|
||||
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 response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/seoul-density/citydata?area=%EA%B0%95%EB%82%A8%EC%97%AD"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.match(calledUrl, /\/seoul-key\/json\/citydata_ppltn\/1\/1\/%EA%B0%95%EB%82%A8%EC%97%AD$/);
|
||||
});
|
||||
|
||||
test("seoul density endpoint returns 503 when proxy server lacks Seoul API key", async (t) => {
|
||||
const app = buildServer();
|
||||
|
||||
t.after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/seoul-density/citydata?area=%EA%B0%95%EB%82%A8%EC%97%AD"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 503);
|
||||
assert.equal(response.json().error, "upstream_not_configured");
|
||||
});
|
||||
|
||||
test("seoul density endpoint returns 400 when area is missing", 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-density/citydata"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.equal(response.json().error, "bad_request");
|
||||
});
|
||||
|
||||
test("proxySeoulCityDataRequest injects API key and encodes area name", async () => {
|
||||
let calledUrl;
|
||||
const result = await proxySeoulCityDataRequest({
|
||||
area: "강남역",
|
||||
apiKey: "test-seoul-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, /\/test-seoul-key\/json\/citydata_ppltn\/1\/1\/%EA%B0%95%EB%82%A8%EC%97%AD$/);
|
||||
});
|
||||
|
||||
test("korea weather endpoint caches successful upstream responses for normalized coordinate queries", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
let fetchCalls = 0;
|
||||
|
|
|
|||
259
scripts/seoul_density.py
Normal file
259
scripts/seoul_density.py
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
"""Single-entrypoint CLI for the seoul-density skill.
|
||||
|
||||
All skill operations route through `python3 seoul-density/scripts/seoul_density.py <subcommand>`
|
||||
so users only have to approve one Bash pattern on first use.
|
||||
|
||||
Subcommands:
|
||||
list — print supported area names grouped by category
|
||||
match <keyword> — fuzzy-match a user keyword to a supported area name
|
||||
query <area-name> [--json] — fetch and summarize real-time density for the area
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import difflib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
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
|
||||
|
||||
|
||||
AREAS: dict[str, list[str]] = {
|
||||
"고궁·문화유산": [
|
||||
"경복궁", "광화문·덕수궁", "보신각", "서울 암사동 유적", "창덕궁·종묘",
|
||||
],
|
||||
"관광특구": [
|
||||
"강남 MICE 관광특구", "동대문 관광특구", "명동 관광특구", "이태원 관광특구",
|
||||
"잠실 관광특구", "종로·청계 관광특구", "홍대 관광특구",
|
||||
],
|
||||
"공원": [
|
||||
"강서한강공원", "고척돔", "광나루한강공원", "광화문광장",
|
||||
"국립중앙박물관·용산가족공원", "난지한강공원", "남산공원", "노들섬",
|
||||
"뚝섬한강공원", "망원한강공원", "반포한강공원", "보라매공원",
|
||||
"북서울꿈의숲", "서대문독립공원", "서리풀공원·몽마르뜨공원", "서울대공원",
|
||||
"서울숲공원", "송현녹지광장", "아차산", "안양천", "양화한강공원",
|
||||
"어린이대공원", "여의도한강공원", "여의서로", "올림픽공원", "월드컵공원",
|
||||
"응봉산", "이촌한강공원", "잠실종합운동장", "잠실한강공원", "잠원한강공원",
|
||||
"청계산", "홍제폭포",
|
||||
],
|
||||
"발달상권": [
|
||||
"가락시장", "가로수길", "광장(전통)시장", "김포공항", "남대문시장", "노량진",
|
||||
"덕수궁길·정동길", "북창동 먹자골목", "북촌한옥마을", "서촌", "성수카페거리",
|
||||
"송리단길·호수단길", "신촌 스타광장", "압구정로데오거리", "여의도", "연남동",
|
||||
"영등포 타임스퀘어", "용리단길", "이태원 앤틱가구거리", "익선동", "인사동",
|
||||
"잠실롯데타워·석촌호수", "창동 신경제 중심지", "청담동 명품거리",
|
||||
"청량리 제기동 일대 전통시장", "해방촌·경리단길", "DDP(동대문디자인플라자)",
|
||||
"DMC(디지털미디어시티)",
|
||||
],
|
||||
"인구밀집지역": [
|
||||
"가산디지털단지역", "강남역", "건대입구역", "고덕역", "고속터미널역", "교대역",
|
||||
"구로디지털단지역", "구로역", "군자역", "대림역", "동대문역", "뚝섬역",
|
||||
"미아사거리역", "발산역", "사당역", "삼각지역", "서울대입구역",
|
||||
"서울식물원·마곡나루역", "서울역", "성신여대입구역", "선릉역", "시의회 앞",
|
||||
"수유역", "신논현역·논현역", "신도림역", "신림역", "신촌·이대역", "쌍문역",
|
||||
"신정네거리역", "역삼역", "연신내역", "양재역", "왕십리역", "용산역",
|
||||
"오목교역·목동운동장", "잠실새내역", "잠실역", "장지역", "장한평역", "천호역",
|
||||
"총신대입구(이수)역", "충정로역", "합정역", "혜화역", "홍대입구역(2호선)",
|
||||
"회기역",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
TIMEOUT_SEC = 10
|
||||
PROXY_BASE_URL_NAME = "KSKILL_PROXY_BASE_URL"
|
||||
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
|
||||
|
||||
|
||||
def all_areas() -> list[str]:
|
||||
return [name for group in AREAS.values() for name in group]
|
||||
|
||||
|
||||
def cmd_list(args: argparse.Namespace) -> int:
|
||||
if args.json:
|
||||
json.dump(AREAS, sys.stdout, ensure_ascii=False, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
return 0
|
||||
for category, names in AREAS.items():
|
||||
print(f"## {category} ({len(names)}곳)")
|
||||
print(", ".join(names))
|
||||
print()
|
||||
return 0
|
||||
|
||||
|
||||
def _normalize(text: str) -> str:
|
||||
"""Strip whitespace and common location suffixes for loose matching."""
|
||||
cleaned = "".join(ch for ch in text if not ch.isspace())
|
||||
for suffix in ("관광특구", "한강공원", "공원", "시장", "역", "거리", "광장"):
|
||||
if cleaned.endswith(suffix) and len(cleaned) > len(suffix):
|
||||
cleaned = cleaned[: -len(suffix)]
|
||||
break
|
||||
return cleaned
|
||||
|
||||
|
||||
def fuzzy_match(keyword: str, limit: int = 5) -> list[str]:
|
||||
names = all_areas()
|
||||
keyword = keyword.strip()
|
||||
if not keyword:
|
||||
return []
|
||||
|
||||
exact = [n for n in names if keyword in n]
|
||||
if exact:
|
||||
return exact[:limit]
|
||||
|
||||
contained = [n for n in names if n in keyword]
|
||||
if contained:
|
||||
return contained[:limit]
|
||||
|
||||
norm_kw = _normalize(keyword)
|
||||
if norm_kw:
|
||||
loose = [n for n in names if norm_kw and (norm_kw in _normalize(n) or _normalize(n) in norm_kw)]
|
||||
if loose:
|
||||
return loose[:limit]
|
||||
|
||||
return difflib.get_close_matches(keyword, names, n=limit, cutoff=0.3)
|
||||
|
||||
|
||||
def cmd_match(args: argparse.Namespace) -> int:
|
||||
matches = fuzzy_match(args.keyword, limit=args.limit)
|
||||
if not matches:
|
||||
print(f"'{args.keyword}'와 일치하는 지원 장소가 없습니다.", file=sys.stderr)
|
||||
print("'python3 seoul-density/scripts/seoul_density.py list' 로 전체 목록을 확인하세요.", file=sys.stderr)
|
||||
return 1
|
||||
if args.json:
|
||||
json.dump(matches, sys.stdout, ensure_ascii=False)
|
||||
sys.stdout.write("\n")
|
||||
else:
|
||||
for name in matches:
|
||||
print(name)
|
||||
return 0
|
||||
|
||||
|
||||
def get_proxy_base_url() -> str:
|
||||
value = os.environ.get(PROXY_BASE_URL_NAME)
|
||||
if value and value != "replace-me":
|
||||
return value.rstrip("/")
|
||||
return DEFAULT_PROXY_BASE_URL
|
||||
|
||||
|
||||
def fetch_density_via_proxy(area: str) -> dict[str, Any]:
|
||||
base_url = get_proxy_base_url()
|
||||
query = urllib.parse.urlencode({"area": area})
|
||||
url = f"{base_url}/v1/seoul-density/citydata?{query}"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "k-skill/seoul-density"})
|
||||
with urllib.request.urlopen(req, timeout=TIMEOUT_SEC) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def summarize(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
result = payload.get("RESULT") or {}
|
||||
code = result.get("RESULT.CODE")
|
||||
message = result.get("RESULT.MESSAGE", "")
|
||||
if code and code != "INFO-000":
|
||||
raise RuntimeError(f"API 오류: {code} {message}".strip())
|
||||
|
||||
rows = payload.get("SeoulRtd.citydata_ppltn") or []
|
||||
if not rows:
|
||||
raise RuntimeError("인구 데이터가 없습니다. 장소명을 'match' 서브커맨드로 확인하세요.")
|
||||
|
||||
row = rows[0]
|
||||
return {
|
||||
"area": row.get("AREA_NM"),
|
||||
"congestion_level": row.get("AREA_CONGEST_LVL"),
|
||||
"population_min": row.get("AREA_PPLTN_MIN"),
|
||||
"population_max": row.get("AREA_PPLTN_MAX"),
|
||||
"as_of": row.get("PPLTN_TIME"),
|
||||
"message": row.get("AREA_CONGEST_MSG"),
|
||||
}
|
||||
|
||||
|
||||
def cmd_query(args: argparse.Namespace) -> int:
|
||||
area = args.area.strip()
|
||||
if area not in all_areas():
|
||||
suggestions = fuzzy_match(area, limit=3)
|
||||
if len(suggestions) == 1 and getattr(args, "auto", True):
|
||||
print(f"'{area}' → '{suggestions[0]}' 로 자동 매칭", file=sys.stderr)
|
||||
area = suggestions[0]
|
||||
else:
|
||||
hint = (
|
||||
f" 가까운 후보: {', '.join(suggestions)}" if suggestions else ""
|
||||
)
|
||||
print(f"지원하지 않는 장소: {area}{hint}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
payload = fetch_density_via_proxy(area)
|
||||
summary = summarize(payload)
|
||||
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 (RuntimeError, json.JSONDecodeError) as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.json:
|
||||
json.dump(summary, sys.stdout, ensure_ascii=False, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
return 0
|
||||
|
||||
print(f"장소: {summary['area']}")
|
||||
print(f"혼잡도: {summary['congestion_level']}")
|
||||
print(f"인구 추정: {summary['population_min']}~{summary['population_max']}명")
|
||||
print(f"기준 시각: {summary['as_of'] or '알 수 없음'}")
|
||||
print(f"상황: {summary['message']}")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="seoul_density",
|
||||
description="서울 실시간 도시데이터(혼잡도/인구) 단일 진입점 CLI",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p_list = sub.add_parser("list", help="지원 장소 목록 출력")
|
||||
p_list.add_argument("--json", action="store_true")
|
||||
p_list.set_defaults(func=cmd_list)
|
||||
|
||||
p_match = sub.add_parser("match", help="키워드 → 지원 장소명 매칭")
|
||||
p_match.add_argument("keyword")
|
||||
p_match.add_argument("--limit", type=int, default=5)
|
||||
p_match.add_argument("--json", action="store_true")
|
||||
p_match.set_defaults(func=cmd_match)
|
||||
|
||||
p_query = sub.add_parser("query", help="장소 혼잡도 조회")
|
||||
p_query.add_argument("area", help="지원 장소명 (목록은 'list' 참조)")
|
||||
p_query.add_argument("--json", action="store_true")
|
||||
p_query.add_argument(
|
||||
"--no-auto",
|
||||
dest="auto",
|
||||
action="store_false",
|
||||
help="후보가 1개뿐이어도 자동 매칭하지 않음",
|
||||
)
|
||||
p_query.set_defaults(func=cmd_query, auto=True)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
152
scripts/test_seoul_density.py
Normal file
152
scripts/test_seoul_density.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"""Tests for seoul_density CLI helpers (no network access)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import unittest
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from unittest import mock
|
||||
|
||||
import seoul_density as sd
|
||||
|
||||
|
||||
class FuzzyMatchTests(unittest.TestCase):
|
||||
def test_exact_substring_wins(self) -> None:
|
||||
result = sd.fuzzy_match("강남역")
|
||||
self.assertIn("강남역", result)
|
||||
|
||||
def test_keyword_contained_in_area(self) -> None:
|
||||
result = sd.fuzzy_match("홍대")
|
||||
self.assertTrue(any("홍대" in name for name in result))
|
||||
|
||||
def test_close_match_fallback(self) -> None:
|
||||
result = sd.fuzzy_match("여의도공원")
|
||||
self.assertTrue(result, "close match should return at least one candidate")
|
||||
|
||||
def test_loose_match_strips_역_suffix(self) -> None:
|
||||
result = sd.fuzzy_match("강남")
|
||||
self.assertIn("강남역", result)
|
||||
|
||||
|
||||
class SummarizeTests(unittest.TestCase):
|
||||
def test_ok_payload(self) -> None:
|
||||
payload = {
|
||||
"RESULT": {"RESULT.CODE": "INFO-000", "RESULT.MESSAGE": "OK"},
|
||||
"SeoulRtd.citydata_ppltn": [
|
||||
{
|
||||
"AREA_NM": "강남역",
|
||||
"AREA_CONGEST_LVL": "붐빔",
|
||||
"AREA_PPLTN_MIN": "30000",
|
||||
"AREA_PPLTN_MAX": "32000",
|
||||
"PPLTN_TIME": "2026-05-14 09:30",
|
||||
"AREA_CONGEST_MSG": "평소보다 매우 많은 인파",
|
||||
}
|
||||
],
|
||||
}
|
||||
summary = sd.summarize(payload)
|
||||
self.assertEqual(summary["area"], "강남역")
|
||||
self.assertEqual(summary["congestion_level"], "붐빔")
|
||||
|
||||
def test_api_error_code_raises(self) -> None:
|
||||
payload = {"RESULT": {"RESULT.CODE": "ERROR-300", "RESULT.MESSAGE": "bad key"}}
|
||||
with self.assertRaises(RuntimeError):
|
||||
sd.summarize(payload)
|
||||
|
||||
def test_empty_rows_raises(self) -> None:
|
||||
payload = {"RESULT": {"RESULT.CODE": "INFO-000"}, "SeoulRtd.citydata_ppltn": []}
|
||||
with self.assertRaises(RuntimeError):
|
||||
sd.summarize(payload)
|
||||
|
||||
|
||||
class CLITests(unittest.TestCase):
|
||||
def test_list_json(self) -> None:
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
rc = sd.main(["list", "--json"])
|
||||
self.assertEqual(rc, 0)
|
||||
data = json.loads(buf.getvalue())
|
||||
self.assertIn("관광특구", data)
|
||||
|
||||
def test_match_unknown_keyword(self) -> None:
|
||||
err = io.StringIO()
|
||||
with redirect_stderr(err):
|
||||
rc = sd.main(["match", "절대로_존재하지_않는_장소_xyzzy"])
|
||||
self.assertEqual(rc, 1)
|
||||
|
||||
def test_query_unsupported_area(self) -> None:
|
||||
err = io.StringIO()
|
||||
with redirect_stderr(err):
|
||||
rc = sd.main(["query", "존재하지않는장소xyzzy"])
|
||||
self.assertEqual(rc, 1)
|
||||
|
||||
def test_query_auto_matches_single_candidate(self) -> None:
|
||||
payload = {
|
||||
"RESULT": {"RESULT.CODE": "INFO-000"},
|
||||
"SeoulRtd.citydata_ppltn": [
|
||||
{
|
||||
"AREA_NM": "서울 암사동 유적",
|
||||
"AREA_CONGEST_LVL": "보통",
|
||||
"AREA_PPLTN_MIN": "1000",
|
||||
"AREA_PPLTN_MAX": "1200",
|
||||
"PPLTN_TIME": "2026-05-14 10:00",
|
||||
"AREA_CONGEST_MSG": "평소와 비슷",
|
||||
}
|
||||
],
|
||||
}
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
def fake_proxy(area: str) -> dict:
|
||||
captured["area"] = area
|
||||
return payload
|
||||
|
||||
buf = io.StringIO()
|
||||
err = io.StringIO()
|
||||
with mock.patch.object(sd, "fetch_density_via_proxy", side_effect=fake_proxy), \
|
||||
redirect_stdout(buf), redirect_stderr(err):
|
||||
rc = sd.main(["query", "암사동"])
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(captured.get("area"), "서울 암사동 유적")
|
||||
self.assertIn("자동 매칭", err.getvalue())
|
||||
|
||||
def test_no_auto_disables_single_match(self) -> None:
|
||||
err = io.StringIO()
|
||||
with redirect_stderr(err):
|
||||
rc = sd.main(["query", "암사동", "--no-auto"])
|
||||
self.assertEqual(rc, 1)
|
||||
|
||||
def test_query_happy_path(self) -> None:
|
||||
payload = {
|
||||
"RESULT": {"RESULT.CODE": "INFO-000"},
|
||||
"SeoulRtd.citydata_ppltn": [
|
||||
{
|
||||
"AREA_NM": "강남역",
|
||||
"AREA_CONGEST_LVL": "보통",
|
||||
"AREA_PPLTN_MIN": "10000",
|
||||
"AREA_PPLTN_MAX": "12000",
|
||||
"PPLTN_TIME": "2026-05-14 09:00",
|
||||
"AREA_CONGEST_MSG": "평소와 비슷",
|
||||
}
|
||||
],
|
||||
}
|
||||
buf = io.StringIO()
|
||||
with mock.patch.object(sd, "fetch_density_via_proxy", return_value=payload), \
|
||||
redirect_stdout(buf):
|
||||
rc = sd.main(["query", "강남역", "--json"])
|
||||
self.assertEqual(rc, 0)
|
||||
out = json.loads(buf.getvalue())
|
||||
self.assertEqual(out["congestion_level"], "보통")
|
||||
|
||||
|
||||
class ProxyHelpersTests(unittest.TestCase):
|
||||
def test_proxy_base_url_default(self) -> None:
|
||||
with mock.patch.dict("os.environ", {}, clear=True):
|
||||
self.assertEqual(sd.get_proxy_base_url(), sd.DEFAULT_PROXY_BASE_URL)
|
||||
|
||||
def test_proxy_base_url_custom_strips_trailing_slash(self) -> None:
|
||||
with mock.patch.dict("os.environ", {"KSKILL_PROXY_BASE_URL": "https://example.com/"}, clear=True):
|
||||
self.assertEqual(sd.get_proxy_base_url(), "https://example.com")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
121
seoul-density/SKILL.md
Normal file
121
seoul-density/SKILL.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
---
|
||||
name: seoul-density
|
||||
description: 서울 주요 121개 핫스팟 장소의 실시간 혼잡도와 인구 현황을 조회한다. 지금 강남역이 얼마나 붐비는지, 홍대 인파가 얼마나 되는지 물어볼 때 사용한다.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: utility
|
||||
locale: ko-KR
|
||||
phase: v1
|
||||
---
|
||||
|
||||
# Seoul Density
|
||||
|
||||
## What this skill does
|
||||
|
||||
서울 실시간 도시데이터 API(data.seoul.go.kr)를 호출해 121개 핫스팟의 **현재 혼잡도 단계**(여유 / 보통 / 약간 붐빔 / 붐빔)와 **추정 인구 범위**를 반환한다.
|
||||
|
||||
데이터는 KT·SKT 통신 신호 기반 추계치이며, 5분 주기로 갱신되나 호출 시점 기준 약 15분 전 값이다.
|
||||
|
||||
## When to use
|
||||
|
||||
- "지금 강남역 얼마나 붐벼?"
|
||||
- "홍대 지금 인파 어때?"
|
||||
- "명동 지금 사람 많아?"
|
||||
- "여의도한강공원 지금 여유로워?"
|
||||
|
||||
## Prerequisites
|
||||
|
||||
별도 API 키 발급 없이 그대로 쓸 수 있다. 모든 호출은 **k-skill-proxy 경유**다.
|
||||
|
||||
- 기본 프록시 URL: `https://k-skill-proxy.nomadamas.org` — 프록시 서버가 `SEOUL_OPEN_API_KEY`를 보유하고 있어 사용자는 키 없이 호출만 하면 된다.
|
||||
- `KSKILL_PROXY_BASE_URL` 환경변수로 프록시 주소를 바꿀 수 있다(예: 로컬 개발용 `http://127.0.0.1:4020`).
|
||||
|
||||
## Single entrypoint
|
||||
|
||||
이 스킬의 모든 동작은 **단일 진입점**을 통한다. OS·CWD에 관계없이 동일하게 동작하도록 절대 경로 + Python launcher fallback을 사용한다:
|
||||
|
||||
```bash
|
||||
# macOS / Linux / Git-bash
|
||||
python3 "$SKILL_DIR/scripts/seoul_density.py" <subcommand> [args]
|
||||
|
||||
# Windows (PowerShell): py 런처 또는 python
|
||||
py -3 "$env:SKILL_DIR\scripts\seoul_density.py" <subcommand> [args]
|
||||
```
|
||||
|
||||
`$SKILL_DIR`은 이 SKILL.md가 위치한 디렉토리다(`~/.claude/skills/seoul-density` 또는 레포의 `seoul-density/`). 호출 예시는 아래 Workflow 참조.
|
||||
|
||||
첫 사용 시 `Bash(python3 *seoul_density.py:*)` (또는 PowerShell 환경에서 `PowerShell(py -3 *seoul_density.py*)`) 패턴 한 번만 승인하면 이후 호출은 모두 자동 허용된다. 외부 dependency는 없고 Python 표준 라이브러리만 사용한다.
|
||||
|
||||
|
||||
|
||||
### Subcommands
|
||||
|
||||
| 명령 | 설명 |
|
||||
|------|------|
|
||||
| `list [--json]` | 지원 121개 장소 목록 (카테고리별) |
|
||||
| `match <키워드> [--limit N] [--json]` | 사용자 입력 → 지원 장소명 매칭 |
|
||||
| `query <장소명> [--json]` | 실시간 혼잡도/인구 조회 (사람이 읽는 요약 또는 JSON) |
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. 모호한 입력은 match로 후보 확인 (선택)
|
||||
|
||||
사용자가 "홍대 인파"처럼 모호하게 말하면 먼저 후보를 확인한다.
|
||||
|
||||
```bash
|
||||
python3 "$SKILL_DIR/scripts/seoul_density.py" match "홍대" --json
|
||||
# → ["홍대 관광특구", "홍대입구역(2호선)"]
|
||||
```
|
||||
|
||||
후보가 1개면 바로 `query`로 넘어가도 되고(스크립트가 자동 매칭), 여러 개면 어느 쪽인지 사용자에게 확인한다.
|
||||
|
||||
### 2. 혼잡도 조회
|
||||
|
||||
키워드 1개만 매칭되면 자동으로 보정한다.
|
||||
|
||||
```bash
|
||||
# macOS / Linux / Git-bash
|
||||
python3 "$SKILL_DIR/scripts/seoul_density.py" query "강남역"
|
||||
|
||||
# Windows PowerShell
|
||||
py -3 "$env:SKILL_DIR\scripts\seoul_density.py" query "강남역"
|
||||
```
|
||||
|
||||
출력 예시:
|
||||
|
||||
```
|
||||
장소: 강남역
|
||||
혼잡도: 약간 붐빔
|
||||
인구 추정: 24000~26000명
|
||||
기준 시각: 2026-05-14 09:30
|
||||
상황: 사람이 몰려있을 수 있어요
|
||||
```
|
||||
|
||||
기계적 후처리가 필요하면 `--json` 플래그를 쓴다:
|
||||
|
||||
```bash
|
||||
python3 "$SKILL_DIR/scripts/seoul_density.py" query "강남역" --json
|
||||
```
|
||||
|
||||
자동 매칭을 끄고 싶으면 `--no-auto`를 쓴다.
|
||||
|
||||
## Done when
|
||||
|
||||
- 장소명, 혼잡도 단계, 추정 인구 범위(최소~최대), 기준 시각, 혼잡도 메시지를 사용자에게 전달했다.
|
||||
|
||||
## Failure modes
|
||||
|
||||
| 상황 | 동작 |
|
||||
|------|------|
|
||||
| 프록시 정상 응답 | 별도 키 불필요, 즉시 결과 반환 |
|
||||
| 지원하지 않는 장소명 (`exit 1`) | `match` 결과로 후보 제안 |
|
||||
| 프록시 HTTP/네트워크 오류 (`exit 1`) | stderr에 사유 출력, `KSKILL_PROXY_BASE_URL` 점검 또는 5분 후 재시도 안내 |
|
||||
| 새벽 01~05시 빈 응답 | 실시간 데이터 미제공 시간대임을 안내 |
|
||||
| 일일 할당량 초과 | 다음 날 재시도 안내 |
|
||||
|
||||
## Notes
|
||||
|
||||
- 인구 수치는 실제값이 아닌 **추계치** (KT·SKT 통신 신호 데이터 기반).
|
||||
- 데이터는 호출 시점 기준 **약 15분 전** 값.
|
||||
- 단일 진입점 외에 `curl`, `python3 -c`, `source` 같은 inline 명령을 직접 실행하지 말 것. 그렇게 하면 사용자가 매번 별도 승인을 받아야 한다.
|
||||
- 새 카테고리/장소가 추가되면 `seoul-density/scripts/seoul_density.py`의 `AREAS` 딕셔너리만 갱신한다.
|
||||
Loading…
Add table
Add a link
Reference in a new issue