Merge branch 'dev' into feature/#228

This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-15 00:19:51 +09:00
commit 8baf3adc23
37 changed files with 3164 additions and 22 deletions

View file

@ -0,0 +1,5 @@
---
"gangnamunni-clinic-search": minor
---
Add Gangnam Unni public clinic search skill and package.

View file

@ -0,0 +1,5 @@
---
"k-skill-proxy": patch
---
Add National Tax Service business registration status and authenticity proxy routes.

View 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`.

View file

@ -16,14 +16,31 @@ permissions:
id-token: write
jobs:
detect_python_packages:
runs-on: ubuntu-latest
outputs:
has_python_packages: ${{ steps.detect.outputs.has_python_packages }}
steps:
- uses: actions/checkout@v4
- id: detect
shell: bash
run: |
if find python-packages -mindepth 2 -maxdepth 2 -name pyproject.toml -print -quit | grep -q .; then
echo "has_python_packages=true" >> "$GITHUB_OUTPUT"
else
echo "has_python_packages=false" >> "$GITHUB_OUTPUT"
fi
scaffold-only:
if: ${{ hashFiles('python-packages/**/pyproject.toml') == '' }}
needs: detect_python_packages
if: ${{ needs.detect_python_packages.outputs.has_python_packages != 'true' }}
runs-on: ubuntu-latest
steps:
- run: echo "No Python package exists yet. release-please remains scaffold-only."
release:
if: ${{ hashFiles('python-packages/**/pyproject.toml') != '' }}
needs: detect_python_packages
if: ${{ needs.detect_python_packages.outputs.has_python_packages == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

View file

@ -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) |
@ -38,6 +39,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 한국 법령 검색 | `korean-law-search` | 한국 법령/조문/판례/유권해석 검색 | 불필요 | [한국 법령 검색 가이드](docs/features/korean-law-search.md) |
| 등기부등본 자동화 | `iros-registry-automation` | 인터넷등기소(IROS)에서 법인/부동산 등기부등본 장바구니, 수동 결제 후 열람·저장 흐름을 보조 | 필요(수동 로그인·결제/TouchEn) | [등기부등본 자동화 가이드](docs/features/iros-registry-automation.md) |
| 법인등기 신청 컨설팅 | `corporate-registration-consulting` | 일반 영리 주식회사 발기설립 기준으로 법인명·이사·주소 등 사용자 결정사항을 받아 표준 정관, 설립등기 첨부서류, 등록면허세·과밀억제권역 중과 체크, rhwp 기반 HWP 양식 순차 작성 흐름을 참고용으로 안내 | 불필요 | [법인등기 신청 컨설팅 가이드](docs/features/corporate-registration-consulting.md) |
| 사업자등록정보 확인 | `nts-business-registration` | 국세청 사업자등록번호 상태조회와 사업자등록정보 진위확인(공공데이터포털 API, 프록시 경유) | 불필요 | [사업자등록정보 확인 가이드](docs/features/nts-business-registration.md) |
| 한국 사업자 장부 자동화 | `korean-jangbu-for` | `kimlawtech/korean-jangbu-for` 기반 카드·은행·영수증·세금계산서 입력 → 표준 거래내역·계정과목·세무사 전달 CSV·경영 리포트 생성 thin wrapper | 선택사항(CODEF BYOK 자동 수집 시 필요) | [한국 사업자 장부 자동화 가이드](docs/features/korean-jangbu-for.md) |
| 한국 개인정보처리방침·이용약관 자동 생성 | `korean-privacy-terms` | Next.js 프로젝트에 개인정보보호법·약관규제법·전자상거래법 기반 개인정보처리방침/이용약관/쿠키 배너/동의 모달을 생성하는 `kimlawtech/korean-privacy-terms` (Apache-2.0) thin wrapper | 불필요 | [한국 개인정보처리방침·이용약관 자동 생성 가이드](docs/features/korean-privacy-terms.md) |
| 한국 부동산 실거래가 조회 | `real-estate-search` | 아파트/오피스텔/빌라/단독주택 실거래가·전월세·지역코드 조회 | 불필요 | [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md) |
@ -77,6 +79,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 근처 술집 조회 | `kakao-bar-nearby` | 현재 위치 기준 영업 상태·메뉴·좌석·전화번호가 포함된 근처 술집 조회 | 불필요 | [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md) |
| 우편번호 검색 | `zipcode-search` | 주소 키워드로 우편번호 + 공식 영문주소 조회 | 불필요 | [우편번호 검색 가이드](docs/features/zipcode-search.md) |
| 다이소 상품 조회 | `daiso-product-search` | 다이소 매장별 상품 픽업 가능 여부 확인 (정확한 매장별 재고 수량은 다이소몰 보안 정책으로 2026-05-05 부터 차단됨) | 불필요 | [다이소 상품 조회 가이드](docs/features/daiso-product-search.md) |
| 강남언니 병원 조회 | `gangnamunni-clinic-search` | 강남언니 공개 검색 페이지에서 성형외과·피부과 병원 후보, 평점, 리뷰 수, 지원 언어, 공개 링크 조회 | 불필요 | [강남언니 병원 조회 가이드](docs/features/gangnamunni-clinic-search.md) |
| 마켓컬리 상품 조회 | `market-kurly-search` | 마켓컬리 상품 검색, 현재 가격, 할인 여부, 품절 여부 조회 | 불필요 | [마켓컬리 상품 조회 가이드](docs/features/market-kurly-search.md) |
| 올리브영 검색 | `olive-young-search` | 올리브영 매장·상품·재고 조회 | 불필요 | [올리브영 검색 가이드](docs/features/olive-young-search.md) |
| 올라포케 역삼 포케 | `hola-poke-yeoksam` | 올라포케 역삼점 메뉴, 매장 정보, 이벤트 참여 흐름 안내 | 불필요 | [올라포케 역삼 포케 가이드](docs/features/hola-poke-yeoksam.md) |
@ -137,6 +140,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)
@ -145,6 +149,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [한강 수위 정보 가이드](docs/features/han-river-water-level.md)
- [한국 법령 검색 가이드](docs/features/korean-law-search.md)
- [한국 개인정보처리방침·이용약관 자동 생성 가이드](docs/features/korean-privacy-terms.md)
- [사업자등록정보 확인 가이드](docs/features/nts-business-registration.md)
- [한국 사업자 장부 자동화 가이드](docs/features/korean-jangbu-for.md)
- [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md)
- [개별공시지가 조회 가이드](docs/features/gongsijiga-search.md)
@ -184,6 +189,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md)
- [우편번호 검색](docs/features/zipcode-search.md)
- [다이소 상품 조회](docs/features/daiso-product-search.md)
- [강남언니 병원 조회 가이드](docs/features/gangnamunni-clinic-search.md)
- [마켓컬리 상품 조회 가이드](docs/features/market-kurly-search.md)
- [올리브영 검색 가이드](docs/features/olive-young-search.md)
- [올라포케 역삼 포케 가이드](docs/features/hola-poke-yeoksam.md)

View file

@ -0,0 +1,32 @@
# 강남언니 병원 조회 가이드
`gangnamunni-clinic-search`는 강남언니 공개 검색 페이지에서 병원 후보를 조회하는 read-only 스킬입니다.
## 공개 접근 경로
- 검색 URL: `https://www.gangnamunni.com/search?q=<keyword>`
- 데이터 위치: HTML 안의 `__NEXT_DATA__` JSON (`props.pageProps.hospitals`)
- 인증/시크릿: 불필요
- 프록시: 사용하지 않음
## 예시
```bash
npx gangnamunni-clinic-search "강남 성형외과" --limit 5
```
```js
const { searchClinics } = require("gangnamunni-clinic-search")
const result = await searchClinics({ query: "코성형", limit: 3 })
```
## 출력
각 후보는 공개 검색 페이지에 포함된 병원명, 평점, 리뷰 수, 지원 언어, 이미지 URL, 공개 병원 링크를 포함합니다.
## 제한사항
- 조회 시점 공개 검색 결과 기준입니다.
- 로그인, 상담, 예약, 결제, 찜, 리뷰 작성은 자동화하지 않습니다.
- CAPTCHA/차단/로그인벽/빈 shell 페이지는 실패 모드로 처리합니다.
- 의료 판단이나 병원 선택 보증을 대신하지 않습니다.

View file

@ -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

View file

@ -0,0 +1,50 @@
# 국세청 사업자등록정보 진위확인 및 상태조회
`nts-business-registration` 스킬은 공공데이터포털의 **국세청_사업자등록정보 진위확인 및 상태조회 서비스**를 `k-skill-proxy` 경유로 호출한다.
## 제공 기능
- 사업자등록번호 상태조회: `POST /v1/nts-business/status`
- 사업자등록정보 진위확인: `POST /v1/nts-business/validate`
## 인증/시크릿
사용자 로컬 시크릿은 필요 없다. upstream `DATA_GO_KR_API_KEY`는 프록시 서버에만 둔다.
self-host 프록시를 쓰는 경우에만 `KSKILL_PROXY_BASE_URL`을 설정한다. 비우면 hosted proxy(`https://k-skill-proxy.nomadamas.org`)를 사용한다.
## 진위확인 개인정보 경로
`/v1/nts-business/validate`는 대표자명(`p_nm`), 개업일자(`start_dt`), 선택 주소/상호 메타데이터를 hosted proxy와 공공데이터포털 upstream으로 전송한다. proxy는 validate 성공 응답을 캐시하지 않고(`status` 조회만 성공 캐시), 응답에 normalized `query`를 echo하지 않으며, upstream 응답이 요청값을 되돌려도 민감 필드를 제거한다.
기본 proxy 서버는 Fastify request logging을 켜지 않는다. self-host 운영자가 별도 요청 로깅을 활성화했다면 validate 요청 본문이 저장되지 않도록 로그 정책을 확인해야 한다. hosted proxy 대신 자체 운영 경로가 필요하면 `KSKILL_PROXY_BASE_URL`로 self-host proxy를 지정한다.
## 예시
```bash
python3 nts-business-registration/scripts/nts_business_registration.py status \
--b-no 123-45-67890
```
```bash
python3 nts-business-registration/scripts/nts_business_registration.py validate \
--business-json '{"b_no":"123-45-67890","start_dt":"2020-01-31","p_nm":"홍길동","b_nm":"테스트상사"}'
```
## 입력 제한
- 사업자등록번호는 숫자 10자리여야 한다. 하이픈은 자동 제거한다.
- 상태조회/진위확인은 한 번에 최대 100건까지 보낸다.
- 진위확인은 `b_no`, `start_dt`, `p_nm`이 필수다.
- 선택 필드: `p_nm2`, `b_nm`, `corp_no`, `b_sector`, `b_type`, `b_adr`
- 길이 제한: `p_nm`/`p_nm2` 30자, `b_nm` 200자, `b_sector`/`b_type` 100자, `b_adr` 500자. `corp_no`는 제공 시 숫자 13자리여야 한다.
## 실패 모드
- `400 bad_request`: 입력 형식 오류 또는 필수 필드 누락
- `503 upstream_not_configured`: 프록시 서버에 `DATA_GO_KR_API_KEY` 없음
- upstream 인증/활용신청 오류: 공공데이터포털 키가 해당 서비스에 승인되지 않았거나 오류 상태
## 공식 출처
- 공공데이터포털: <https://www.data.go.kr/tcs/dss/selectApiDataDetailView.do?publicDataPk=15081808>

View 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)

View file

@ -81,6 +81,7 @@ npx --yes skills add <owner/repo> \
--skill geeknews-search \
--skill daiso-product-search \
--skill market-kurly-search \
--skill gangnamunni-clinic-search \
--skill olive-young-search \
--skill hola-poke-yeoksam \
--skill blue-ribbon-nearby \
@ -118,6 +119,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 \
@ -283,7 +285,7 @@ npm run ci
### Node 패키지
```bash
npm install -g kordoc pdfjs-dist kbo-game kbl-results kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby public-restroom-nearby korean-law-mcp market-kurly-search daiso bunjang-cli court-auction-notice-search gongsijiga-search donation-place-search
npm install -g kordoc pdfjs-dist kbo-game kbl-results kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby public-restroom-nearby korean-law-mcp market-kurly-search daiso bunjang-cli court-auction-notice-search gongsijiga-search donation-place-search gangnamunni-clinic-search
export NODE_PATH="$(npm root -g)"
```
@ -361,6 +363,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`

View file

@ -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

View file

@ -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)

View file

@ -113,6 +113,8 @@
- 다이소몰 매장 픽업 재고: https://www.daisomall.co.kr/api/pd/pdh/selStrPkupStck (2026-05-05 기준 Unauthorized 차단 가능)
- 다이소몰 매장 픽업 가능 매장 목록: https://www.daisomall.co.kr/api/ms/msg/selPkupStr (특정 상품의 픽업 가능 매장 리스트, 매장 수량은 미제공)
- 다이소몰 온라인 재고: https://www.daisomall.co.kr/api/pdo/selOnlStck
- 강남언니 공개 검색: https://www.gangnamunni.com/search?q=<keyword>
- 강남언니 공개 병원 페이지: https://www.gangnamunni.com/hospitals/<id>
- 마켓컬리 검색 API(v4): https://api.kurly.com/search/v4/sites/market/normal-search
- 마켓컬리 검색 개수 API(v3): https://api.kurly.com/search/v3/sites/market/normal-search/count
- 마켓컬리 상품 상세 페이지 예시: https://www.kurly.com/goods/5063110
@ -158,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

View file

@ -0,0 +1,123 @@
---
name: gangnamunni-clinic-search
description: 강남언니 공개 검색 페이지에서 성형외과·피부과 병원 후보, 평점, 리뷰 수, 지원 언어, 공개 병원 링크를 조회한다.
license: MIT
metadata:
category: beauty
locale: ko-KR
phase: v1
---
# Gangnam Unni Clinic Search
## What this skill does
강남언니(Gangnam Unni) 웹 검색 페이지의 **비로그인 공개 Next.js payload**를 읽어 병원 후보를 조회한다.
- 키워드로 병원 후보를 검색한다.
- 공개 검색 결과에 포함된 평점, 평점 수, 리뷰 수, 지원 언어, 공개 이미지, 병원 링크를 정리한다.
- 예약, 상담, 결제, 리뷰 작성, 앱 로그인 등 사용자 계정이 필요한 액션은 하지 않는다.
## When to use
- "강남언니에서 강남 성형외과 찾아줘"
- "강남언니 병원 평점이랑 리뷰 수 봐줘"
- "코성형 병원 후보를 강남언니 기준으로 몇 개만 보여줘"
- "성형외과/피부과 병원 공개 링크를 찾아줘"
## When not to use
- 상담 신청, 예약, 결제, 병원 채팅, 찜 같은 계정 기반 액션이 필요한 경우
- 로그인 사용자에게만 보이는 이벤트, 가격, 개인화 추천을 확정해야 하는 경우
- 의료적 판단, 시술 적합성, 안전성 보증을 대신해야 하는 경우
## Prerequisites
- 인터넷 연결
- Node.js 18+
- 이 저장소의 `gangnamunni-clinic-search` package 또는 동일 로직
## Required inputs
### 1. Ask for a search keyword if it is missing
검색어가 없으면 먼저 확인한다.
- 권장 질문: `강남언니에서 찾을 병원/시술/지역 키워드를 알려주세요. 예: 강남 성형외과, 코성형, 피부과`
- 너무 넓으면: `검색어가 넓어요. 지역이나 시술명을 같이 주시면 후보를 더 좁힐 수 있어요.`
### 2. Keep the answer conservative
강남언니 공개 페이지 기준으로 확인한 후보임을 분명히 말한다. 병원 선택, 의료 조언, 수술 권유처럼 해석될 수 있는 표현은 피한다.
## Public Gangnam Unni surface
- search list: `https://www.gangnamunni.com/search?q=<keyword>`
- parsed payload: `<script id="__NEXT_DATA__" type="application/json">...props.pageProps.hospitals...</script>`
- public hospital URL: `https://www.gangnamunni.com/hospitals/<id>`
Discovery result: `curl`/Node fetch로 비로그인 검색 HTML이 200으로 응답하고, 병원 후보는 server-rendered `__NEXT_DATA__``props.pageProps.hospitals` 배열에 포함된다. 이 경로는 공개 read-only endpoint이므로 `k-skill-proxy`를 사용하지 않는다.
## Workflow
### 1. Search by keyword
```js
const { searchClinics } = require("gangnamunni-clinic-search")
const result = await searchClinics({ query: "강남 성형외과", limit: 5 })
console.log(result.items)
```
CLI:
```bash
npx gangnamunni-clinic-search "강남 성형외과" --limit 5
```
### 2. Interpret returned fields
우선 아래 필드를 본다.
- `name`: 병원명
- `rating`, `ratingCount`, `reviewCount`: 공개 검색 페이지에 포함된 평점/리뷰 지표
- `languages`: 공개 지원 언어
- `url`: 강남언니 공개 병원 페이지
- `profileImage`, `mainImage`: 공개 이미지 URL
### 3. Fallback order
1. 기본: `https://www.gangnamunni.com/search?q=<keyword>``__NEXT_DATA__` payload를 파싱한다.
2. payload가 없으면 로그인벽, CAPTCHA, 차단, 빈 shell 페이지를 실패 모드로 분류한다.
3. 검색 결과가 너무 적거나 앱 전용 정보가 필요하면 자동화를 멈추고 사용자가 공식 앱/웹에서 직접 확인하도록 안내한다.
### 4. Respond safely
응답은 짧고 보수적으로 정리한다.
- 병원명
- 공개 평점/리뷰 수
- 지원 언어
- 강남언니 공개 링크
- `조회 시점 공개 검색 결과 기준이며, 의료 판단이나 실제 예약 가능 여부는 병원/공식 앱에서 확인해야 합니다.` 라고 명시한다.
## Done when
- 검색 키워드를 확인했다.
- 공개 검색 결과에서 병원 후보를 반환했거나, 실패 모드를 명확히 설명했다.
- 계정 기반 액션과 의료 판단은 하지 않았다.
## Failure modes
- 검색어가 너무 넓거나 강남언니가 병원 후보를 공개 payload에 일부만 넣을 수 있다.
- 강남언니 웹 구조가 바뀌면 `__NEXT_DATA__` 경로가 깨질 수 있다.
- 로그인 필요, CAPTCHA, 접근 차단, 빈 HTML shell은 자동 우회하지 않고 실패로 보고한다.
- 평점, 리뷰 수, 노출 순서는 시점에 따라 달라진다.
- 앱 전용/로그인 전용 정보는 비로그인 공개 조회만으로 확정할 수 없다.
## Notes
- 조회형 스킬이다.
- 비로그인 공개 표면 우선 원칙을 유지한다.
- 프록시와 API key는 사용하지 않는다.
- 의료 조언이나 병원 추천 보증이 아니라 공개 후보 정리로만 답한다.

View file

@ -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`

View file

@ -0,0 +1,125 @@
---
name: nts-business-registration
description: 국세청 사업자등록정보 진위확인 및 사업자등록 상태조회를 공공데이터포털 API(k-skill-proxy 경유)로 수행한다.
license: MIT
metadata:
category: business
locale: ko-KR
phase: v1
---
# 국세청 사업자등록정보 진위확인 및 상태조회
## What this skill does
공공데이터포털의 **국세청_사업자등록정보 진위확인 및 상태조회 서비스**를 `k-skill-proxy` 경유로 호출해 다음을 확인한다.
- `status`: 사업자등록번호 기준 상태조회 (`계속사업자`, `휴업자`, `폐업자`, 과세유형 등 upstream 응답 그대로 포함)
- `validate`: 사업자등록번호 + 개업일자 + 대표자명(및 선택 필드) 기준 진위확인
## When to use
- "이 사업자등록번호가 계속사업자인지 확인해줘"
- "사업자등록번호 상태조회해줘"
- "사업자등록번호, 개업일, 대표자명으로 진위확인해줘"
- 거래처 등록 전 공식 NTS/공공데이터포털 기준 확인이 필요할 때
## Prerequisites
- 인터넷 연결
- `python3`
- 설치된 skill payload 안에 `scripts/nts_business_registration.py` helper 포함
- hosted/self-host `k-skill-proxy``/v1/nts-business/status`, `/v1/nts-business/validate` route 접근 가능
## Credential requirements
- 사용자 측 필수 시크릿 없음.
- `KSKILL_PROXY_BASE_URL` — self-host·별도 프록시를 쓸 때만 설정. 비우면 기본 hosted `https://k-skill-proxy.nomadamas.org` 를 사용한다.
- `DATA_GO_KR_API_KEY` 는 프록시 운영 서버 환경에만 둔다. 공공데이터포털에서 `국세청_사업자등록정보 진위확인 및 상태조회 서비스` 활용신청이 되어 있어야 한다.
## Validate privacy boundary
- `validate`는 대표자명(`p_nm`), 개업일자(`start_dt`), 주소·상호 같은 선택 메타데이터를 hosted proxy와 공공데이터포털 upstream으로 전송한다.
- hosted proxy는 `validate` 성공 응답을 캐시하지 않고, 프록시 `query` echo를 붙이지 않으며, upstream이 요청값을 되돌려도 민감 입력 필드를 응답에서 제거한다.
- 프록시의 기본 Fastify request logging은 꺼져 있다. 운영자가 별도 로그를 켠 self-host 환경에서는 요청 본문 로깅 정책을 직접 점검해야 한다.
- hosted proxy 경유가 부담스러운 진위확인 업무는 `KSKILL_PROXY_BASE_URL`로 직접 운영하는 self-host proxy를 지정한다.
## Official surfaces
- 공공데이터포털 문서: `https://www.data.go.kr/tcs/dss/selectApiDataDetailView.do?publicDataPk=15081808`
- 상태조회 upstream: `POST https://api.odcloud.kr/api/nts-businessman/v1/status?serviceKey=...`
- 진위확인 upstream: `POST https://api.odcloud.kr/api/nts-businessman/v1/validate?serviceKey=...`
- 프록시 route: `POST /v1/nts-business/status`, `POST /v1/nts-business/validate`
## Inputs
### 상태조회
- `b_no`: 사업자등록번호 10자리. 하이픈은 허용되며 helper/proxy가 숫자만 남긴다.
- 한 요청은 최대 100개까지 보낸다.
### 진위확인
필수:
- `b_no`: 사업자등록번호 10자리
- `start_dt`: 개업일자 `YYYYMMDD` (하이픈/점 허용)
- `p_nm`: 대표자 성명
선택:
- `p_nm2`: 대표자 성명2
- `b_nm`: 상호
- `corp_no`: 법인등록번호
- `b_sector`: 주업태명
- `b_type`: 주종목명
- `b_adr`: 사업장주소
텍스트 필드는 NTS 입력 규격에 맞춰 보수적으로 길이를 제한한다(`p_nm`/`p_nm2` 30자, `b_nm` 200자, `b_sector`/`b_type` 100자, `b_adr` 500자). `corp_no`는 제공할 경우 숫자 13자리여야 한다.
## Workflow
1. 사용자 입력에서 사업자등록번호는 숫자 10자리인지 확인한다.
2. 상태조회만 필요하면 `status`를 호출한다.
3. 진위확인은 최소 `b_no`, `start_dt`, `p_nm`이 있을 때만 호출한다.
4. 개인정보/거래처 정보는 필요한 필드만 보내고, 프록시 응답을 그대로 보존하되 핵심 상태/진위 결과를 짧게 요약한다.
5. upstream이 `upstream_not_configured`, 활용신청 미승인, 인증키 오류 등을 반환하면 설정/승인 문제로 안내한다.
## CLI examples
```bash
python3 scripts/nts_business_registration.py status \
--b-no 123-45-67890
```
```bash
python3 scripts/nts_business_registration.py validate \
--business-json '{"b_no":"123-45-67890","start_dt":"2020-01-31","p_nm":"홍길동","b_nm":"테스트상사"}'
```
## Direct proxy examples
```bash
curl -fsS -X POST "$KSKILL_PROXY_BASE_URL/v1/nts-business/status" \
-H 'content-type: application/json' \
-d '{"b_no":["123-45-67890"]}'
```
```bash
curl -fsS -X POST "$KSKILL_PROXY_BASE_URL/v1/nts-business/validate" \
-H 'content-type: application/json' \
-d '{"businesses":[{"b_no":"123-45-67890","start_dt":"20200131","p_nm":"홍길동"}]}'
```
## Failure modes
- `400 bad_request`: 사업자등록번호가 10자리가 아니거나 진위확인 필수 필드가 빠짐.
- `503 upstream_not_configured`: 프록시 서버에 `DATA_GO_KR_API_KEY`가 없음.
- upstream 인증/활용신청 오류: API 키가 해당 서비스에 승인되지 않았거나 만료/오류 상태.
- 빈 결과 또는 진위불일치: 공식 응답의 `valid`, `valid_msg`, `b_stt` 값을 그대로 근거로 설명한다.
## Done when
- 상태조회는 공식 응답의 `b_stt`, `b_stt_cd`, `tax_type` 등 핵심 필드를 확인했다.
- 진위확인은 `valid`, `valid_msg` 결과를 확인했다.
- API 키는 사용자에게 요구하지 않고 프록시 서버에만 둔다는 점을 지켰다.

View file

@ -0,0 +1,211 @@
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import re
import sys
import urllib.error
import urllib.request
from typing import Any
PROXY_BASE_URL_ENV_VAR = "KSKILL_PROXY_BASE_URL"
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
BATCH_LIMIT = 100
VALIDATE_TEXT_FIELD_LIMITS = {
"p_nm": 30,
"p_nm2": 30,
"b_nm": 200,
"b_sector": 100,
"b_type": 100,
"b_adr": 500,
}
class ApiError(RuntimeError):
def __init__(self, message: str, *, status_code: int | None = None, url: str | None = None):
super().__init__(message)
self.status_code = status_code
self.url = url
def _text_or_none(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def resolve_proxy_base_url(explicit_base_url: str | None = None, env: dict[str, str] | None = None) -> str:
env = os.environ if env is None else env
candidate = _text_or_none(explicit_base_url or env.get(PROXY_BASE_URL_ENV_VAR))
if candidate and candidate.casefold() in {"off", "false", "0", "disable", "disabled", "none"}:
raise ValueError("KSKILL_PROXY_BASE_URL 가 비활성화되어 있습니다.")
if candidate and candidate != "replace-me":
return candidate.rstrip("/")
return DEFAULT_PROXY_BASE_URL
def normalize_business_number(value: Any) -> str:
raw = _text_or_none(value)
if not raw:
raise ValueError("사업자등록번호(b_no)를 입력하세요.")
normalized = re.sub(r"\D", "", raw)
if not re.fullmatch(r"\d{10}", normalized):
raise ValueError("사업자등록번호는 숫자 10자리여야 합니다.")
return normalized
def normalize_start_date(value: Any) -> str:
raw = _text_or_none(value)
if not raw:
raise ValueError("개업일자(start_dt)를 YYYYMMDD 형식으로 입력하세요.")
normalized = re.sub(r"\D", "", raw)
if not re.fullmatch(r"\d{8}", normalized):
raise ValueError("개업일자는 YYYYMMDD 형식이어야 합니다.")
try:
dt.date(int(normalized[:4]), int(normalized[4:6]), int(normalized[6:8]))
except ValueError as error:
raise ValueError("개업일자는 유효한 날짜여야 합니다.") from error
return normalized
def normalize_validate_text(value: Any, field_name: str, *, required: bool = False) -> str | None:
text = _text_or_none(value)
if not text:
if required:
raise ValueError(f"{field_name}을(를) 입력하세요.")
return None
max_length = VALIDATE_TEXT_FIELD_LIMITS.get(field_name)
if max_length and len(text) > max_length:
raise ValueError(f"{field_name}은(는) {max_length}자 이하여야 합니다.")
return text
def normalize_corp_no(value: Any) -> str | None:
raw = _text_or_none(value)
if not raw:
return None
normalized = re.sub(r"\D", "", raw)
if not re.fullmatch(r"\d{13}", normalized):
raise ValueError("corp_no는 숫자 13자리여야 합니다.")
return normalized
def build_status_payload(business_numbers: list[Any]) -> dict[str, list[str]]:
numbers = [normalize_business_number(value) for value in business_numbers]
numbers = list(dict.fromkeys(numbers))
if not numbers:
raise ValueError("사업자등록번호를 1개 이상 입력하세요.")
if len(numbers) > BATCH_LIMIT:
raise ValueError("한 번에 조회할 수 있는 사업자등록번호는 100개까지입니다.")
return {"b_no": numbers}
def build_validate_business(**kwargs: Any) -> dict[str, str]:
p_nm = normalize_validate_text(kwargs.get("p_nm"), "p_nm", required=True)
business = {
"b_no": normalize_business_number(kwargs.get("b_no")),
"start_dt": normalize_start_date(kwargs.get("start_dt")),
"p_nm": p_nm,
}
for key in ("p_nm2", "b_nm", "b_sector", "b_type", "b_adr"):
value = normalize_validate_text(kwargs.get(key), key)
if value:
business[key] = value
corp_no = normalize_corp_no(kwargs.get("corp_no"))
if corp_no:
business["corp_no"] = corp_no
return business
def build_validate_payload(businesses: list[dict[str, Any]]) -> dict[str, list[dict[str, str]]]:
if not businesses:
raise ValueError("진위확인 대상 businesses를 1개 이상 입력하세요.")
if len(businesses) > BATCH_LIMIT:
raise ValueError("한 번에 진위확인할 수 있는 사업자는 100개까지입니다.")
return {"businesses": [build_validate_business(**business) for business in businesses]}
def read_json_response(request: urllib.request.Request) -> dict[str, Any]:
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
try:
payload = json.loads(body)
except json.JSONDecodeError:
payload = None
if isinstance(payload, dict) and payload.get("message"):
raise ApiError(str(payload["message"]), status_code=error.code, url=getattr(error, "url", None)) from error
raise ApiError(f"NTS business proxy request failed with HTTP {error.code}", status_code=error.code, url=getattr(error, "url", None)) from error
except urllib.error.URLError as error:
raise ApiError(f"NTS business proxy request failed: {error.reason}") from error
def _post_json(path: str, payload: dict[str, Any], *, base_url: str | None = None, read_json: Any = read_json_response) -> dict[str, Any]:
resolved_base_url = resolve_proxy_base_url(base_url)
request = urllib.request.Request(
f"{resolved_base_url}{path}",
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": "k-skill-nts-business-registration/1.0",
},
method="POST",
)
return read_json(request)
def query_status(business_numbers: list[Any], *, base_url: str | None = None, read_json: Any = read_json_response) -> dict[str, Any]:
return _post_json("/v1/nts-business/status", build_status_payload(business_numbers), base_url=base_url, read_json=read_json)
def validate_businesses(businesses: list[dict[str, Any]], *, base_url: str | None = None, read_json: Any = read_json_response) -> dict[str, Any]:
return _post_json("/v1/nts-business/validate", build_validate_payload(businesses), base_url=base_url, read_json=read_json)
def _parse_business_json(value: str) -> dict[str, Any]:
payload = json.loads(value)
if not isinstance(payload, dict):
raise argparse.ArgumentTypeError("business JSON must be an object")
return payload
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="NTS business registration status/authenticity helper")
subparsers = parser.add_subparsers(dest="command", required=True)
status = subparsers.add_parser("status", help="사업자등록번호 상태조회")
status.add_argument("--b-no", action="append", required=True, help="사업자등록번호(10자리; 하이픈 허용). 여러 번 지정 가능")
status.add_argument("--proxy-base-url")
validate = subparsers.add_parser("validate", help="사업자등록정보 진위확인")
validate.add_argument("--business-json", action="append", type=_parse_business_json, required=True, help='예: {"b_no":"1234567890","start_dt":"20200101","p_nm":"홍길동"}')
validate.add_argument("--proxy-base-url")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
if args.command == "status":
print(json.dumps(query_status(args.b_no, base_url=args.proxy_base_url), ensure_ascii=False, indent=2))
return 0
if args.command == "validate":
print(json.dumps(validate_businesses(args.business_json, base_url=args.proxy_base_url), ensure_ascii=False, indent=2))
return 0
except (ValueError, ApiError) as error:
print(json.dumps({"error": str(error)}, ensure_ascii=False, indent=2), file=sys.stderr)
return 1
return 1
if __name__ == "__main__":
raise SystemExit(main())

14
package-lock.json generated
View file

@ -845,6 +845,10 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/gangnamunni-clinic-search": {
"resolved": "packages/gangnamunni-clinic-search",
"link": true
},
"node_modules/glob-parent": {
"version": "5.1.2",
"dev": true,
@ -1783,6 +1787,16 @@
"node": ">=18"
}
},
"packages/gangnamunni-clinic-search": {
"version": "0.1.0",
"license": "MIT",
"bin": {
"gangnamunni-clinic-search": "src/cli.js"
},
"engines": {
"node": ">=18"
}
},
"packages/gongsijiga-search": {
"version": "0.1.0",
"license": "MIT",

View file

@ -10,10 +10,10 @@
"scripts": {
"build": "npm run build --workspaces --if-present",
"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/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 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/ticket_availability.py scripts/test_ticket_availability.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.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 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/ticket_availability.py scripts/test_ticket_availability.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.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 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",
"test": "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_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_ticket_availability && 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' && 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 daishin-report-search --dry-run",
"test": "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_ticket_availability && 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' && 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",
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
"version-packages": "changeset version",
"release:npm": "changeset publish"

View file

@ -0,0 +1,35 @@
# gangnamunni-clinic-search
Public Gangnam Unni clinic lookup client for the `gangnamunni-clinic-search` k-skill.
## Source
- Search page: `https://www.gangnamunni.com/search?q=<keyword>`
- Data path: the server-rendered Next.js `__NEXT_DATA__` payload, specifically `props.pageProps.hospitals` and related count fields.
This is an unauthenticated public web surface. No proxy or API key is required. The client does not automate login, appointments, chat, payment, reviews, or app-only flows.
## Usage
```js
const { searchClinics } = require("gangnamunni-clinic-search")
const result = await searchClinics({
query: "강남 성형외과",
limit: 5
})
console.log(result.items)
```
CLI:
```bash
npx gangnamunni-clinic-search "강남 성형외과" --limit 5
```
Returned clinic fields include `id`, `name`, `rating`, `ratingCount`, `reviewCount`, `pageCount`, supported `languages`, public image URLs, and the public Gangnam Unni hospital page URL.
## Failure modes
The parser classifies missing embedded Next.js data, login-required responses, CAPTCHA challenges, and blocked responses separately. Result counts and clinic information are point-in-time public page data and may differ from the mobile app or logged-in experience.

View file

@ -0,0 +1,35 @@
{
"name": "gangnamunni-clinic-search",
"version": "0.1.0",
"description": "Public Gangnam Unni clinic search client for k-skill",
"license": "MIT",
"main": "src/index.js",
"bin": {
"gangnamunni-clinic-search": "src/cli.js"
},
"files": [
"src",
"README.md"
],
"engines": {
"node": ">=18"
},
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/NomaDamas/k-skill.git"
},
"keywords": [
"k-skill",
"gangnamunni",
"clinic",
"plastic-surgery",
"k-beauty"
],
"scripts": {
"lint": "node --check src/index.js && node --check src/cli.js && node --check test/index.test.js",
"test": "node --test"
}
}

View file

@ -0,0 +1,45 @@
#!/usr/bin/env node
const { searchClinics } = require("./index")
async function main(options = parseArgs(process.argv.slice(2)), io = console) {
const result = await searchClinics(options)
io.log(JSON.stringify(result, null, 2))
}
function parseArgs(argv) {
const options = {}
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]
if (arg === "--query" || arg === "-q") options.query = argv[++i] || ""
else if (arg === "--limit") options.limit = Number(argv[++i])
else if (arg === "--debug") options.debug = true
else if (arg === "--help" || arg === "-h") {
printHelp()
process.exit(0)
} else if (!options.query) {
options.query = arg
}
}
return options
}
function printHelp() {
console.log(`Usage: gangnamunni-clinic-search [query] [options]\n\nOptions:\n -q, --query <text> Search keyword, e.g. "강남 성형외과"\n --limit <number> Maximum clinic results (default: 5)\n --debug Print stack traces for troubleshooting\n`)
}
function formatError(error, options = {}) {
if (options.debug && error && error.stack) return error.stack
return error && error.message ? error.message : String(error)
}
function run(argv = process.argv.slice(2), io = console) {
const options = parseArgs(argv)
return main(options, io).catch((error) => {
io.error(formatError(error, options))
process.exitCode = 1
})
}
if (require.main === module) run()
module.exports = { parseArgs, printHelp, formatError, run, main }

View file

@ -0,0 +1,188 @@
const GANGNAMUNNI_ORIGIN = "https://www.gangnamunni.com"
const GANGNAMUNNI_SEARCH_URL = `${GANGNAMUNNI_ORIGIN}/search`
const SOURCE_ID = "gangnamunni-search-next-data"
function buildSearchUrl(query) {
const params = new URLSearchParams({ q: String(query || "") })
return `${GANGNAMUNNI_SEARCH_URL}?${params.toString()}`
}
async function searchClinics(options = {}) {
const { query, limit = 5, fetcher = global.fetch, signal, timeoutMs = 10000 } = options
const normalizedQuery = cleanText(query)
if (!normalizedQuery) throw new Error("query is required for Gangnam Unni clinic search")
if (!fetcher) throw new Error("fetch is required")
const url = buildSearchUrl(normalizedQuery)
const requestOptions = {
headers: {
"user-agent": "Mozilla/5.0 (compatible; k-skill/gangnamunni-clinic-search)",
accept: "text/html,application/xhtml+xml"
}
}
const requestSignal = signal || createTimeoutSignal(timeoutMs)
if (requestSignal) requestOptions.signal = requestSignal
const response = await fetcher(url, requestOptions)
if (!response || !response.ok) {
const status = response ? `${response.status} ${response.statusText || ""}`.trim() : "no response"
throw new Error(`request failed for ${redactSearchUrl(url)}: ${status}`)
}
const html = await response.text()
return parseSearchHtml(html, { query: normalizedQuery, limit, sourceUrl: url })
}
function parseSearchHtml(html, options = {}) {
const { query = "", limit = 5, sourceUrl = buildSearchUrl(query) } = options
const normalizedLimit = Math.max(1, Number(limit) || 5)
const data = parseNextData(html)
const pageProps = (((data || {}).props || {}).pageProps) || {}
const hospitals = Array.isArray(pageProps.hospitals) ? pageProps.hospitals : []
const parsed = hospitals.map(normalizeHospital).filter((item) => item.id && item.name)
const items = parsed.slice(0, normalizedLimit)
const warnings = []
if (hospitals.length === 0 && Number(pageProps.hospitalTotalLength || 0) > 0) {
warnings.push(`Gangnam Unni reported ${pageProps.hospitalTotalLength} hospitals but embedded no hospital list items`)
}
if (parsed.length > items.length) warnings.push(`returned ${items.length} of ${parsed.length} parsed hospitals; increase limit for more`)
if (Number(pageProps.hospitalTotalLength || 0) > parsed.length) {
warnings.push(`public search page embedded ${parsed.length} of ${pageProps.hospitalTotalLength} matching hospitals`)
}
return {
query: cleanText(pageProps.keyword) || cleanText(query),
totalLength: numericOrNull(pageProps.totalLength),
hospitalTotalLength: numericOrNull(pageProps.hospitalTotalLength),
sourceUrl,
sources: [SOURCE_ID],
warnings,
items
}
}
function parseNextData(html) {
const source = String(html || "")
classifyBlockedBody(source)
const match = source.match(/<script\b[^>]*id=["']__NEXT_DATA__["'][^>]*>([\s\S]*?)<\/script>/i)
if (!match) throw new Error("Gangnam Unni next data payload not found")
const payload = match[1].trim()
try {
return JSON.parse(payload)
} catch (rawError) {
try {
return JSON.parse(decodeHtmlEntities(payload))
} catch (decodedError) {
const message = `Gangnam Unni next data payload could not be parsed: ${rawError.message}`
throw new Error(`${message}; decoded fallback failed: ${decodedError.message}`)
}
}
}
function createTimeoutSignal(timeoutMs) {
const numericTimeoutMs = Number(timeoutMs)
if (!Number.isFinite(numericTimeoutMs) || numericTimeoutMs <= 0) return null
if (typeof AbortSignal === "undefined" || typeof AbortSignal.timeout !== "function") return null
return AbortSignal.timeout(numericTimeoutMs)
}
function redactSearchUrl(value) {
try {
const url = new URL(String(value))
const serialized = url.toString()
return serialized.replace(/([?&]q=)[^&]*/i, "$1<redacted>")
} catch {
return String(value || "").replace(/([?&]q=)[^&]*/i, "$1<redacted>")
}
}
function classifyBlockedBody(source) {
const text = cleanText(htmlToText(source)).toLowerCase()
if (!text) return
if (/captcha|recaptcha|로봇이 아닙니다|자동화된 요청/.test(text)) throw new Error("Gangnam Unni captcha challenge encountered")
if (/access denied|forbidden|request blocked|too many requests|temporarily blocked|접근이 제한/.test(text)) {
throw new Error("Gangnam Unni request blocked")
}
if (/로그인(이|을)? 필요|sign in required|login required/.test(text)) throw new Error("Gangnam Unni login required")
}
function normalizeHospital(hospital) {
const id = Number(hospital && hospital.id)
return compactObject({
id: Number.isFinite(id) ? id : null,
name: cleanText(hospital && hospital.name),
rating: numericOrNull(hospital && hospital.rating),
ratingCount: numericOrNull(hospital && hospital.ratingCount),
reviewCount: numericOrNull(hospital && hospital.reviewCount),
pageCount: numericOrNull(hospital && hospital.pageCount),
languages: Array.isArray(hospital && hospital.supportingLangList) ? hospital.supportingLangList.filter(Boolean) : [],
assessmentState: cleanText(hospital && hospital.assessmentState),
sido: cleanText(hospital && hospital.sido),
profileImage: safeHttpsUrl(hospital && hospital.profileImage),
mainImage: safeHttpsUrl(hospital && hospital.mainImage),
url: Number.isFinite(id) ? `${GANGNAMUNNI_ORIGIN}/hospitals/${id}` : null
})
}
function compactObject(value) {
return Object.fromEntries(Object.entries(value).filter(([, entry]) => {
if (entry === null || entry === undefined || entry === "") return false
if (Array.isArray(entry) && entry.length === 0) return false
return true
}))
}
function numericOrNull(value) {
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric : null
}
function safeHttpsUrl(value) {
const text = cleanText(value)
if (!text) return null
try {
const url = new URL(text)
return url.protocol === "https:" ? url.toString() : null
} catch {
return null
}
}
function htmlToText(html) {
return String(html || "")
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]+>/g, " ")
}
function decodeHtmlEntities(value) {
return String(value || "")
.replace(/&quot;/g, '"')
.replace(/&#34;/g, '"')
.replace(/&#x22;/gi, '"')
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&#39;/g, "'")
.replace(/&#x27;/gi, "'")
}
function cleanText(value) {
return String(value == null ? "" : value).replace(/\s+/g, " ").trim()
}
module.exports = {
GANGNAMUNNI_ORIGIN,
GANGNAMUNNI_SEARCH_URL,
SOURCE_ID,
buildSearchUrl,
searchClinics,
parseSearchHtml,
parseNextData,
normalizeHospital,
createTimeoutSignal,
redactSearchUrl,
cleanText
}

View file

@ -0,0 +1,195 @@
const test = require("node:test")
const assert = require("node:assert/strict")
const { spawnSync } = require("node:child_process")
const {
buildSearchUrl,
parseNextData,
normalizeHospital,
parseSearchHtml,
searchClinics
} = require("../src/index")
const sampleNextData = {
props: {
pageProps: {
keyword: "강남 성형외과",
totalLength: 14216,
hospitalTotalLength: 4,
hospitals: [
{
id: 347,
name: "강남삼성성형외과의원",
rating: 9,
ratingCount: 675,
reviewCount: 764,
pageCount: 0,
profileImage: "https://image2.gnsister.com/images/hospital/profile/sample.jpg",
mainImage: "https://image2.gnsister.com/images/hospital/main.jpg",
supportingLangList: ["ko", "ja", "en"],
assessmentState: "EFFORT",
sido: "서울"
},
{
id: 543,
name: "강남서연성형외과의원",
rating: 9.4,
ratingCount: 39,
reviewCount: 83,
pageCount: 8,
profileImage: "https://image2.gnsister.com/images/hospital/profile/other.jpg",
mainImage: "https://image2.gnsister.com/images/hospital/other-main.jpg",
supportingLangList: ["ko", "zh-Hans", "ja"],
assessmentState: "EFFORT",
sido: ""
}
]
}
}
}
const sampleHtml = `<!doctype html><html><body>
<script id="__NEXT_DATA__" type="application/json">${JSON.stringify(sampleNextData).replace(/</g, "\\u003c")}</script>
</body></html>`
test("buildSearchUrl uses the public Gangnam Unni search page", () => {
const url = buildSearchUrl("강남 성형외과")
assert.equal(url, "https://www.gangnamunni.com/search?q=%EA%B0%95%EB%82%A8+%EC%84%B1%ED%98%95%EC%99%B8%EA%B3%BC")
})
test("parseNextData reads escaped Next.js JSON payloads", () => {
const data = parseNextData(sampleHtml)
assert.equal(data.props.pageProps.keyword, "강남 성형외과")
assert.equal(data.props.pageProps.hospitals.length, 2)
})
test("parseNextData preserves literal entity-looking text inside valid JSON strings", () => {
const data = {
props: {
pageProps: {
hospitals: [{ id: 1, name: "A &quot; Clinic &amp; Care" }]
}
}
}
const html = `<script id="__NEXT_DATA__" type="application/json">${JSON.stringify(data)}</script>`
const parsed = parseNextData(html)
assert.equal(parsed.props.pageProps.hospitals[0].name, "A &quot; Clinic &amp; Care")
})
test("parseNextData falls back to entity-decoded legacy payloads", () => {
const html = `<script id="__NEXT_DATA__" type="application/json">{&quot;props&quot;:{&quot;pageProps&quot;:{&quot;keyword&quot;:&quot;강남&quot;}}}</script>`
const parsed = parseNextData(html)
assert.equal(parsed.props.pageProps.keyword, "강남")
})
test("parseNextData classifies login, captcha, blocked, and empty-shell failures", () => {
assert.throws(() => parseNextData("로그인이 필요합니다"), /login required/i)
assert.throws(() => parseNextData("captcha challenge"), /captcha/i)
assert.throws(() => parseNextData("Access Denied"), /blocked/i)
assert.throws(() => parseNextData("<html></html>"), /next data/i)
})
test("normalizeHospital publishes stable public clinic fields only", () => {
assert.deepEqual(normalizeHospital(sampleNextData.props.pageProps.hospitals[0]), {
id: 347,
name: "강남삼성성형외과의원",
rating: 9,
ratingCount: 675,
reviewCount: 764,
pageCount: 0,
languages: ["ko", "ja", "en"],
assessmentState: "EFFORT",
sido: "서울",
profileImage: "https://image2.gnsister.com/images/hospital/profile/sample.jpg",
mainImage: "https://image2.gnsister.com/images/hospital/main.jpg",
url: "https://www.gangnamunni.com/hospitals/347"
})
})
test("parseSearchHtml returns query metadata, limited clinic items, source, and warnings", () => {
const result = parseSearchHtml(sampleHtml, { query: "강남 성형외과", limit: 1 })
assert.equal(result.query, "강남 성형외과")
assert.equal(result.totalLength, 14216)
assert.equal(result.hospitalTotalLength, 4)
assert.equal(result.items.length, 1)
assert.equal(result.items[0].name, "강남삼성성형외과의원")
assert.deepEqual(result.sources, ["gangnamunni-search-next-data"])
assert.match(result.warnings.join("\n"), /returned 1 of 2 parsed hospitals/)
})
test("searchClinics fetches the search page with a default timeout and parses clinics", async () => {
const seen = []
const fetcher = async (url, options) => {
seen.push({ url: String(url), headers: options.headers, signal: options.signal })
return {
ok: true,
status: 200,
statusText: "OK",
text: async () => sampleHtml
}
}
const result = await searchClinics({ query: "강남 성형외과", limit: 2, fetcher })
assert.equal(seen[0].url, buildSearchUrl("강남 성형외과"))
assert.match(seen[0].headers["user-agent"], /k-skill\/gangnamunni-clinic-search/)
assert.ok(seen[0].signal, "expected a default abort signal")
assert.equal(result.items.length, 2)
})
test("searchClinics lets callers inject an abort signal", async () => {
const controller = new AbortController()
let seenSignal
const fetcher = async (_url, options) => {
seenSignal = options.signal
return { ok: true, status: 200, statusText: "OK", text: async () => sampleHtml }
}
await searchClinics({ query: "강남", fetcher, signal: controller.signal })
assert.equal(seenSignal, controller.signal)
})
test("searchClinics rejects missing query and failed upstream responses", async () => {
await assert.rejects(() => searchClinics({ query: "" }), /query is required/)
await assert.rejects(
() => searchClinics({
query: "강남",
fetcher: async () => ({ ok: false, status: 503, statusText: "Service Unavailable" })
}),
(error) => {
assert.match(error.message, /request failed.*503 Service Unavailable/)
assert.match(error.message, /q=<redacted>/)
assert.doesNotMatch(error.message, /%EA%B0%95%EB%82%A8|강남/)
return true
}
)
})
test("CLI parses options and supports help", () => {
const cli = require("../src/cli")
assert.deepEqual(cli.parseArgs(["강남 성형외과", "--limit", "3", "--debug"]), {
query: "강남 성형외과",
limit: 3,
debug: true
})
assert.equal(cli.formatError(new Error("plain failure"), { debug: false }), "plain failure")
assert.match(cli.formatError(new Error("debug failure"), { debug: true }), /Error: debug failure/)
const help = spawnSync(process.execPath, ["src/cli.js", "--help"], {
cwd: __dirname + "/..",
encoding: "utf8"
})
assert.equal(help.status, 0)
assert.match(help.stdout, /Usage: gangnamunni-clinic-search/)
})

View file

@ -8,11 +8,14 @@
- `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`)
- `GET /v1/neis/school-search` — 나이스 학교기본정보(교육청명·학교명 검색)
- `GET /v1/neis/school-meal` — 나이스 급식식단정보(일자별 메뉴)
- `POST /v1/nts-business/status` — 국세청 사업자등록 상태조회(`DATA_GO_KR_API_KEY`)
- `POST /v1/nts-business/validate` — 국세청 사업자등록정보 진위확인(`DATA_GO_KR_API_KEY`)
- `GET /v1/mfds/drug-safety/lookup` — 식약처 의약품개요정보(e약은요) + 안전상비의약품 정보(`DATA_GO_KR_API_KEY`)
- `GET /v1/mfds/food-safety/search` — 식약처 부적합 식품 + 식품안전나라 회수 정보(`DATA_GO_KR_API_KEY`, 선택적 `FOODSAFETYKOREA_API_KEY`)
- `GET /v1/korean-stock/search`
@ -60,7 +63,7 @@
- `KSKILL_PROXY_CACHE_TTL_MS` — 기본 `300000`
- `KSKILL_PROXY_RATE_LIMIT_WINDOW_MS` — 기본 `60000`
- `KSKILL_PROXY_RATE_LIMIT_MAX` — 기본 `60`
- `DATA_GO_KR_API_KEY` - 공공데이터포털 에서 쓰이는 API 인증키 (`household-waste`, `parking-lots`, `real-estate`, `mfds-drug-safety`, `mfds-food-safety`, `lh-notice`). 각 서비스는 공공데이터포털에서 별도 "활용신청" 승인이 필요하다. 키를 발급받은 뒤에는 [LH 임대공고문 정보](https://www.data.go.kr/data/15058530/openapi.do) 페이지에서도 활용신청을 눌러 동일 키를 활성화해야 `lh-notice` 라우트가 성공한다. 미활성 상태에서는 upstream이 HTTP 403 Forbidden을 돌려주고 proxy는 `upstream_error`로 변환한다.
- `DATA_GO_KR_API_KEY` - 공공데이터포털 에서 쓰이는 API 인증키 (`household-waste`, `parking-lots`, `real-estate`, `nts-business`, `mfds-drug-safety`, `mfds-food-safety`, `lh-notice`). 각 서비스는 공공데이터포털에서 별도 "활용신청" 승인이 필요하다. 키를 발급받은 뒤에는 [LH 임대공고문 정보](https://www.data.go.kr/data/15058530/openapi.do) 페이지에서도 활용신청을 눌러 동일 키를 활성화해야 `lh-notice` 라우트가 성공한다. 미활성 상태에서는 upstream이 HTTP 403 Forbidden을 돌려주고 proxy는 `upstream_error`로 변환한다.
기본 정책은 **무료 API 공개 프록시 = 무인증** 이다. 대신 endpoint scope 를 좁게 유지하고, cache + rate limit 으로 남용을 늦춘다.
@ -72,6 +75,15 @@ node packages/k-skill-proxy/src/server.js
환경변수(`AIR_KOREA_OPEN_API_KEY` 등)가 이미 설정되어 있거나 `~/.config/k-skill/secrets.env`를 source한 상태에서 실행한다.
국세청 사업자등록 상태조회 예시:
```bash
curl -fsS -X POST 'http://127.0.0.1:4020/v1/nts-business/status' \
-H 'content-type: application/json' \
-d '{"b_no":["123-45-67890"]}'
```
서울 지하철 도착정보 예시:
```bash
@ -79,6 +91,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

View file

@ -9,7 +9,7 @@
"node": ">=18"
},
"scripts": {
"lint": "node --check src/airkorea.js && node --check src/bluer.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/lh-notice.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-news.js && node --check src/naver-shopping.js && node --check src/parking-lots.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/lh-notice.test.js && node --check test/molit.test.js && node --check test/naver-news.test.js && node --check test/naver-shopping.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
"lint": "node --check src/airkorea.js && node --check src/bluer.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/lh-notice.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-news.js && node --check src/naver-shopping.js && node --check src/nts-business.js && node --check src/parking-lots.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/lh-notice.test.js && node --check test/molit.test.js && node --check test/naver-news.test.js && node --check test/naver-shopping.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
"test": "node --test"
},
"dependencies": {

View file

@ -0,0 +1,206 @@
const NTS_BUSINESSMAN_UPSTREAM_BASE_URL = "https://api.odcloud.kr/api/nts-businessman/v1";
const NTS_BATCH_LIMIT = 100;
const NTS_BUSINESS_OPERATIONS = new Set(["status", "validate"]);
const NTS_VALIDATE_OPTIONAL_TEXT_FIELDS = ["p_nm2", "b_nm", "b_sector", "b_type", "b_adr"];
const NTS_VALIDATE_TEXT_FIELD_LIMITS = {
p_nm: 30,
p_nm2: 30,
b_nm: 200,
b_sector: 100,
b_type: 100,
b_adr: 500
};
function trimOrNull(value) {
if (value === undefined || value === null) {
return null;
}
const trimmed = String(value).trim();
return trimmed ? trimmed : null;
}
function normalizeBusinessNumber(value) {
const raw = trimOrNull(value);
if (!raw) {
throw new Error("Provide business registration number (b_no). business registration number must be 10 digits.");
}
const normalized = raw.replace(/[^0-9]/g, "");
if (!/^\d{10}$/.test(normalized)) {
throw new Error("Provide valid business registration number (b_no) as 10 digits.");
}
return normalized;
}
function normalizeNtsBusinessNumbers(value) {
const rawValues = Array.isArray(value) ? value : String(value ?? "").split(",");
const numbers = rawValues
.flatMap((entry) => (Array.isArray(entry) ? entry : [entry]))
.map((entry) => trimOrNull(entry))
.filter(Boolean)
.map(normalizeBusinessNumber);
const unique = [...new Set(numbers)];
if (unique.length === 0) {
throw new Error("Provide b_no as one or more business registration numbers.");
}
if (unique.length > NTS_BATCH_LIMIT) {
throw new Error(`Provide up to ${NTS_BATCH_LIMIT} business registration numbers per request.`);
}
return unique;
}
function normalizeNtsStartDate(value) {
const raw = trimOrNull(value);
if (!raw) {
throw new Error("Provide start_dt as YYYYMMDD.");
}
const normalized = raw.replace(/[^0-9]/g, "");
if (!/^\d{8}$/.test(normalized)) {
throw new Error("Provide start_dt as YYYYMMDD.");
}
const year = Number.parseInt(normalized.slice(0, 4), 10);
const month = Number.parseInt(normalized.slice(4, 6), 10);
const day = Number.parseInt(normalized.slice(6, 8), 10);
const date = new Date(Date.UTC(year, month - 1, day));
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
throw new Error("Provide start_dt as a valid YYYYMMDD date.");
}
return normalized;
}
function normalizeOptionalDigits(value, label) {
const raw = trimOrNull(value);
if (!raw) {
return null;
}
const normalized = raw.replace(/[^0-9]/g, "");
if (!normalized) {
throw new Error(`Provide valid ${label}.`);
}
return normalized;
}
function normalizeNtsValidateText(value, fieldName, { required = false } = {}) {
const normalized = trimOrNull(value);
if (!normalized) {
if (required) {
throw new Error(`Provide ${fieldName} for each business.`);
}
return null;
}
const maxLength = NTS_VALIDATE_TEXT_FIELD_LIMITS[fieldName];
if (maxLength && normalized.length > maxLength) {
throw new Error(`Provide ${fieldName} up to ${maxLength} characters.`);
}
return normalized;
}
function normalizeNtsBusinessStatusQuery(body = {}) {
return {
b_no: normalizeNtsBusinessNumbers(body.b_no ?? body.business_numbers ?? body.businessNumbers)
};
}
function normalizeNtsBusinessValidateItem(item) {
if (!item || typeof item !== "object" || Array.isArray(item)) {
throw new Error("Each business must be an object.");
}
const pNm = normalizeNtsValidateText(
item.p_nm ?? item.owner_name ?? item.ownerName ?? item.representative_name,
"p_nm",
{ required: true }
);
const normalized = {
b_no: normalizeBusinessNumber(item.b_no ?? item.business_number ?? item.businessNumber),
start_dt: normalizeNtsStartDate(item.start_dt ?? item.startDate ?? item.opening_date),
p_nm: pNm
};
for (const key of NTS_VALIDATE_OPTIONAL_TEXT_FIELDS) {
const value = normalizeNtsValidateText(item[key], key);
if (value) {
normalized[key] = value;
}
}
const corpNo = normalizeOptionalDigits(item.corp_no ?? item.corpNo, "corp_no");
if (corpNo) {
if (!/^\d{13}$/.test(corpNo)) {
throw new Error("Provide valid corp_no as 13 digits.");
}
normalized.corp_no = corpNo;
}
return normalized;
}
function normalizeNtsBusinessValidateQuery(body = {}) {
const businesses = body.businesses;
if (!Array.isArray(businesses) || businesses.length === 0) {
throw new Error("Provide businesses as a non-empty array.");
}
if (businesses.length > NTS_BATCH_LIMIT) {
throw new Error(`Provide up to ${NTS_BATCH_LIMIT} businesses per request.`);
}
return {
businesses: businesses.map(normalizeNtsBusinessValidateItem)
};
}
async function proxyNtsBusinessRequest({ operation, payload, serviceKey, fetchImpl = global.fetch }) {
if (!serviceKey) {
return {
statusCode: 503,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "upstream_not_configured",
message: "DATA_GO_KR_API_KEY is not configured on the proxy server."
})
};
}
if (!NTS_BUSINESS_OPERATIONS.has(operation)) {
return {
statusCode: 404,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "not_found",
message: "That NTS business route is not exposed by this proxy."
})
};
}
const url = new URL(`${NTS_BUSINESSMAN_UPSTREAM_BASE_URL}/${operation}`);
url.searchParams.set("serviceKey", serviceKey);
const response = await fetchImpl(url, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json"
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(20000)
});
return {
statusCode: response.status,
contentType: response.headers.get("content-type") || "application/json; charset=utf-8",
body: await response.text()
};
}
module.exports = {
normalizeBusinessNumber,
normalizeNtsBusinessNumbers,
normalizeNtsBusinessStatusQuery,
normalizeNtsBusinessValidateItem,
normalizeNtsBusinessValidateQuery,
normalizeNtsStartDate,
proxyNtsBusinessRequest
};

View file

@ -22,6 +22,11 @@ const {
const { fetchTransactions, VALID_ASSET_TYPES, VALID_DEAL_TYPES } = require("./molit");
const { fetchNaverNewsSearch, normalizeNaverNewsSearchQuery } = require("./naver-news");
const { fetchNaverShoppingSearch, normalizeNaverShoppingSearchQuery } = require("./naver-shopping");
const {
normalizeNtsBusinessStatusQuery,
normalizeNtsBusinessValidateQuery,
proxyNtsBusinessRequest
} = require("./nts-business");
const { fetchNearbyParkingLots } = require("./parking-lots");
const { searchRegionCode } = require("./region-lookup");
const { resolveEducationOfficeFromNaturalLanguage } = require("./neis-office-codes");
@ -31,6 +36,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 +486,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 +1063,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,
@ -1560,7 +1606,8 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
kosisConfigured: Boolean(config.kosisApiKey),
naverShoppingConfigured: true,
naverSearchApiConfigured: naverSearchKeysPresent,
naverNewsApiConfigured: naverSearchKeysPresent
naverNewsApiConfigured: naverSearchKeysPresent,
ntsBusinessConfigured: Boolean(config.molitApiKey)
},
auth: {
tokenRequired: false
@ -1706,6 +1753,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;
@ -2635,6 +2742,203 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
return payload;
});
function getNtsUpstreamStatusCode(parsed) {
if (!parsed || typeof parsed !== "object") {
return null;
}
return parsed.status_code
?? parsed.statusCode
?? parsed.resultCode
?? parsed.response?.header?.resultCode
?? null;
}
function isNtsUpstreamSemanticFailure(parsed) {
const statusCode = getNtsUpstreamStatusCode(parsed);
if (statusCode === null || statusCode === undefined) {
return false;
}
return !["OK", "00", "0", "SUCCESS"].includes(String(statusCode).toUpperCase());
}
const ntsValidateSensitiveResponseKeys = new Set([
"b_adr",
"b_nm",
"b_sector",
"b_type",
"corp_no",
"p_nm",
"p_nm2",
"start_dt"
]);
function redactNtsBusinessValidateResponse(value) {
if (Array.isArray(value)) {
return value.map(redactNtsBusinessValidateResponse);
}
if (!value || typeof value !== "object") {
return value;
}
return Object.fromEntries(
Object.entries(value)
.filter(([key]) => !ntsValidateSensitiveResponseKeys.has(key))
.map(([key, entryValue]) => [key, redactNtsBusinessValidateResponse(entryValue)])
);
}
async function handleNtsBusinessRoute({
operation,
route,
normalizer,
request,
reply,
cacheSuccess = true,
includeQuery = true,
responseMapper = (body) => body
}) {
let normalized;
try {
normalized = normalizer(request.body || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = cacheSuccess
? makeCacheKey({
route,
...normalized
})
: null;
if (cacheKey) {
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
}
let upstream;
try {
upstream = await proxyNtsBusinessRequest({
operation,
payload: normalized,
serviceKey: config.molitApiKey
});
} catch (error) {
reply.code(502);
return {
error: "proxy_error",
message: "NTS business upstream request failed.",
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
}
let parsed;
try {
parsed = JSON.parse(upstream.body);
} catch {
reply.code(upstream.statusCode >= 400 ? upstream.statusCode : 502);
return {
error: "upstream_invalid_response",
message: "NTS business upstream did not return valid JSON.",
upstream_status: upstream.statusCode,
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
}
const responseBody = responseMapper(parsed);
if (
upstream.statusCode < 200
|| upstream.statusCode >= 300
|| parsed.error
|| isNtsUpstreamSemanticFailure(parsed)
) {
reply.code(upstream.statusCode >= 400 ? upstream.statusCode : 502);
return {
...responseBody,
error: parsed.error || "upstream_error",
upstream_status_code: getNtsUpstreamStatusCode(parsed) || undefined,
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
}
};
}
const payload = {
...responseBody,
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
}
};
if (includeQuery) {
payload.query = normalized;
}
if (cacheKey) {
cache.set(cacheKey, payload, config.cacheTtlMs);
}
return payload;
}
app.post("/v1/nts-business/status", async (request, reply) => handleNtsBusinessRoute({
operation: "status",
route: "nts-business-status",
normalizer: normalizeNtsBusinessStatusQuery,
request,
reply
}));
app.post("/v1/nts-business/validate", async (request, reply) => handleNtsBusinessRoute({
operation: "validate",
route: "nts-business-validate",
normalizer: normalizeNtsBusinessValidateQuery,
cacheSuccess: false,
includeQuery: false,
responseMapper: redactNtsBusinessValidateResponse,
request,
reply
}));
app.get("/v1/mfds/drug-safety/lookup", async (request, reply) => {
let normalized;
@ -3850,9 +4154,12 @@ module.exports = {
normalizeNeisSchoolMealQuery,
normalizeNeisSchoolSearchQuery,
normalizeNaverShoppingSearchQuery,
normalizeNtsBusinessStatusQuery,
normalizeNtsBusinessValidateQuery,
normalizeParkingLotSearchQuery,
normalizeRealEstateQuery,
normalizeRegionCodeQuery,
normalizeSeoulCityDataQuery,
normalizeSeoulSubwayQuery,
proxyAirKoreaRequest,
proxyData4LibraryRequest,
@ -3864,6 +4171,7 @@ module.exports = {
proxyKosisRequest,
fetchNaverShoppingSearch,
proxyOpinetRequest,
proxySeoulCityDataRequest,
proxySeoulSubwayRequest,
resolveLatestKmaForecastBase,
startServer

View file

@ -15,12 +15,15 @@ const {
normalizeKosisDataQuery,
normalizeKosisMetaQuery,
normalizeKosisSearchQuery,
normalizeNtsBusinessStatusQuery,
normalizeNtsBusinessValidateQuery,
proxyAirKoreaRequest,
proxyData4LibraryRequest,
proxyHrfcoWaterLevelRequest,
proxyKakaoLocalRequest,
proxyKosisRequest,
proxyKmaWeatherRequest,
proxySeoulCityDataRequest,
proxySeoulSubwayRequest
} = require("../src/server");
const { resolveEducationOfficeFromNaturalLanguage } = require("../src/neis-office-codes");
@ -151,6 +154,327 @@ test("food-safety search does not cache upstream failures so transient errors se
assert.equal(recallCalls.length, 2, "upstream hit on first (fail) and second (recovered) - third served from cache");
});
test("NTS business normalizers validate status and authenticity payloads", () => {
const tooManyBusinessNumbers = Array.from({ length: 101 }, (_, index) => String(index).padStart(10, "0"));
assert.deepEqual(normalizeNtsBusinessStatusQuery({ b_no: "123-45-67890, 9876543210" }), {
b_no: ["1234567890", "9876543210"]
});
assert.deepEqual(
normalizeNtsBusinessValidateQuery({
businesses: [
{
b_no: "123-45-67890",
start_dt: "2020-01-31",
p_nm: "홍길동",
b_nm: "테스트상사",
corp_no: "110111-1234567"
}
]
}),
{
businesses: [
{
b_no: "1234567890",
start_dt: "20200131",
p_nm: "홍길동",
b_nm: "테스트상사",
corp_no: "1101111234567"
}
]
}
);
assert.throws(() => normalizeNtsBusinessStatusQuery({ b_no: "123" }), /business registration number/);
assert.throws(
() => normalizeNtsBusinessValidateQuery({ businesses: [{ b_no: "1234567890", p_nm: "홍길동" }] }),
/start_dt/
);
assert.throws(
() => normalizeNtsBusinessStatusQuery({ b_no: tooManyBusinessNumbers }),
/up to 100/
);
assert.throws(
() => normalizeNtsBusinessValidateQuery({
businesses: [{ b_no: "1234567890", start_dt: "20200101", p_nm: "홍길동", corp_no: "123" }]
}),
/corp_no/
);
assert.throws(
() => normalizeNtsBusinessValidateQuery({
businesses: [{ b_no: "1234567890", start_dt: "20200101", p_nm: "홍".repeat(31) }]
}),
/p_nm/
);
assert.throws(
() => normalizeNtsBusinessValidateQuery({
businesses: [{ b_no: "1234567890", start_dt: "20200101", p_nm: "홍길동", b_adr: "가".repeat(501) }]
}),
/b_adr/
);
});
test("NTS business status route proxies POST body with service key server-side", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url, options) => {
calls.push({ url: String(url), options });
return new Response(
JSON.stringify({ status_code: "OK", request_cnt: 1, data: [{ b_no: "1234567890", b_stt: "계속사업자" }] }),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "POST",
url: "/v1/nts-business/status",
payload: { b_no: ["123-45-67890"] }
});
const body = response.json();
assert.equal(response.statusCode, 200);
assert.equal(body.data[0].b_stt, "계속사업자");
assert.equal(body.proxy.cache.hit, false);
assert.match(calls[0].url, /\/nts-businessman\/v1\/status\?serviceKey=data-go-key$/);
assert.deepEqual(JSON.parse(calls[0].options.body), { b_no: ["1234567890"] });
assert.equal(calls[0].options.method, "POST");
assert.equal(calls[0].options.headers["content-type"], "application/json");
const cached = await app.inject({
method: "POST",
url: "/v1/nts-business/status",
payload: { b_no: ["1234567890"] }
});
const cachedBody = cached.json();
assert.equal(cached.statusCode, 200);
assert.equal(cachedBody.proxy.cache.hit, true);
assert.equal(calls.length, 1);
});
test("NTS business validate route normalizes businesses and reports missing key", async (t) => {
const missingKeyApp = buildServer();
t.after(async () => {
await missingKeyApp.close();
});
const unavailable = await missingKeyApp.inject({
method: "POST",
url: "/v1/nts-business/validate",
payload: { businesses: [{ b_no: "1234567890", start_dt: "20200101", p_nm: "홍길동" }] }
});
const unavailableBody = unavailable.json();
assert.equal(unavailable.statusCode, 503);
assert.equal(unavailableBody.error, "upstream_not_configured");
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url, options) => {
calls.push({ url: String(url), options });
return new Response(
JSON.stringify({ status_code: "OK", valid_cnt: 1, data: [{ b_no: "1234567890", valid: "01", valid_msg: "확인할 수 있습니다." }] }),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "POST",
url: "/v1/nts-business/validate",
payload: { businesses: [{ b_no: "123-45-67890", start_dt: "2020.01.01", p_nm: "홍길동", p_nm2: "", b_adr: "서울" }] }
});
const body = response.json();
assert.equal(response.statusCode, 200);
assert.equal(body.data[0].valid, "01");
assert.match(calls[0].url, /\/nts-businessman\/v1\/validate\?serviceKey=data-go-key$/);
assert.deepEqual(JSON.parse(calls[0].options.body), {
businesses: [{ b_no: "1234567890", start_dt: "20200101", p_nm: "홍길동", b_adr: "서울" }]
});
});
test("NTS business validate route does not cache or echo sensitive query fields", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url, options) => {
calls.push({ url: String(url), options });
return new Response(
JSON.stringify({
status_code: "OK",
valid_cnt: 1,
data: [{
b_no: "1234567890",
valid: "01",
request_param: {
b_no: "1234567890",
start_dt: "20200101",
p_nm: "홍길동",
b_adr: "서울시 중구"
}
}]
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const payload = {
businesses: [{
b_no: "123-45-67890",
start_dt: "2020.01.01",
p_nm: "홍길동",
b_adr: "서울시 중구"
}]
};
const first = await app.inject({
method: "POST",
url: "/v1/nts-business/validate",
payload
});
const firstBody = first.json();
const firstBodyText = JSON.stringify(firstBody);
assert.equal(first.statusCode, 200);
assert.equal(firstBody.proxy.cache.hit, false);
assert.equal(firstBody.query, undefined, "validate responses must not echo representative/date/address inputs");
assert.equal(firstBodyText.includes("홍길동"), false);
assert.equal(firstBodyText.includes("20200101"), false);
assert.equal(firstBodyText.includes("서울시 중구"), false);
assert.deepEqual(firstBody.data[0].request_param, { b_no: "1234567890" });
const second = await app.inject({
method: "POST",
url: "/v1/nts-business/validate",
payload
});
const secondBody = second.json();
assert.equal(second.statusCode, 200);
assert.equal(secondBody.proxy.cache.hit, false);
assert.equal(calls.length, 2, "validate successes must not be cached because they contain sensitive inputs");
});
test("NTS business semantic upstream failures are non-cacheable errors", async (t) => {
const originalFetch = global.fetch;
let calls = 0;
global.fetch = async () => {
calls += 1;
return new Response(
JSON.stringify({ status_code: "SERVICE_ERROR", message: "upstream service failure" }),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const first = await app.inject({
method: "POST",
url: "/v1/nts-business/status",
payload: { b_no: ["1234567890"] }
});
const firstBody = first.json();
assert.equal(first.statusCode, 502);
assert.equal(firstBody.error, "upstream_error");
assert.equal(firstBody.upstream_status_code, "SERVICE_ERROR");
assert.equal(firstBody.proxy.cache.hit, false);
const second = await app.inject({
method: "POST",
url: "/v1/nts-business/status",
payload: { b_no: ["1234567890"] }
});
assert.equal(second.statusCode, 502);
assert.equal(calls, 2, "semantic upstream failures must not be cached");
});
test("NTS business route maps upstream fetch failures to 502 without caching", async (t) => {
const originalFetch = global.fetch;
let calls = 0;
global.fetch = async () => {
calls += 1;
throw new Error("network down");
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const first = await app.inject({
method: "POST",
url: "/v1/nts-business/status",
payload: { b_no: ["1234567890"] }
});
const firstBody = first.json();
assert.equal(first.statusCode, 502);
assert.equal(firstBody.error, "proxy_error");
assert.equal(firstBody.message, "NTS business upstream request failed.");
const second = await app.inject({
method: "POST",
url: "/v1/nts-business/status",
payload: { b_no: ["1234567890"] }
});
assert.equal(second.statusCode, 502);
assert.equal(calls, 2, "fetch failures must not be cached");
});
test("NTS business route does not leak service keys from upstream fetch exception messages", async (t) => {
const originalFetch = global.fetch;
global.fetch = async (url) => {
throw new Error(`proxy tunnel failed for ${url}`);
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "super-secret-data-go-key" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "POST",
url: "/v1/nts-business/status",
payload: { b_no: ["1234567890"] }
});
const body = response.json();
const bodyText = JSON.stringify(body);
assert.equal(response.statusCode, 502);
assert.equal(body.error, "proxy_error");
assert.equal(body.message, "NTS business upstream request failed.");
assert.equal(bodyText.includes("super-secret-data-go-key"), false);
assert.equal(bodyText.includes("serviceKey"), false);
});
test("health endpoint stays public and reports auth/upstream status", async (t) => {
const app = buildServer({
provider: async () => {
@ -1323,6 +1647,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;

View file

@ -0,0 +1,211 @@
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import re
import sys
import urllib.error
import urllib.request
from typing import Any
PROXY_BASE_URL_ENV_VAR = "KSKILL_PROXY_BASE_URL"
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
BATCH_LIMIT = 100
VALIDATE_TEXT_FIELD_LIMITS = {
"p_nm": 30,
"p_nm2": 30,
"b_nm": 200,
"b_sector": 100,
"b_type": 100,
"b_adr": 500,
}
class ApiError(RuntimeError):
def __init__(self, message: str, *, status_code: int | None = None, url: str | None = None):
super().__init__(message)
self.status_code = status_code
self.url = url
def _text_or_none(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def resolve_proxy_base_url(explicit_base_url: str | None = None, env: dict[str, str] | None = None) -> str:
env = os.environ if env is None else env
candidate = _text_or_none(explicit_base_url or env.get(PROXY_BASE_URL_ENV_VAR))
if candidate and candidate.casefold() in {"off", "false", "0", "disable", "disabled", "none"}:
raise ValueError("KSKILL_PROXY_BASE_URL 가 비활성화되어 있습니다.")
if candidate and candidate != "replace-me":
return candidate.rstrip("/")
return DEFAULT_PROXY_BASE_URL
def normalize_business_number(value: Any) -> str:
raw = _text_or_none(value)
if not raw:
raise ValueError("사업자등록번호(b_no)를 입력하세요.")
normalized = re.sub(r"\D", "", raw)
if not re.fullmatch(r"\d{10}", normalized):
raise ValueError("사업자등록번호는 숫자 10자리여야 합니다.")
return normalized
def normalize_start_date(value: Any) -> str:
raw = _text_or_none(value)
if not raw:
raise ValueError("개업일자(start_dt)를 YYYYMMDD 형식으로 입력하세요.")
normalized = re.sub(r"\D", "", raw)
if not re.fullmatch(r"\d{8}", normalized):
raise ValueError("개업일자는 YYYYMMDD 형식이어야 합니다.")
try:
dt.date(int(normalized[:4]), int(normalized[4:6]), int(normalized[6:8]))
except ValueError as error:
raise ValueError("개업일자는 유효한 날짜여야 합니다.") from error
return normalized
def normalize_validate_text(value: Any, field_name: str, *, required: bool = False) -> str | None:
text = _text_or_none(value)
if not text:
if required:
raise ValueError(f"{field_name}을(를) 입력하세요.")
return None
max_length = VALIDATE_TEXT_FIELD_LIMITS.get(field_name)
if max_length and len(text) > max_length:
raise ValueError(f"{field_name}은(는) {max_length}자 이하여야 합니다.")
return text
def normalize_corp_no(value: Any) -> str | None:
raw = _text_or_none(value)
if not raw:
return None
normalized = re.sub(r"\D", "", raw)
if not re.fullmatch(r"\d{13}", normalized):
raise ValueError("corp_no는 숫자 13자리여야 합니다.")
return normalized
def build_status_payload(business_numbers: list[Any]) -> dict[str, list[str]]:
numbers = [normalize_business_number(value) for value in business_numbers]
numbers = list(dict.fromkeys(numbers))
if not numbers:
raise ValueError("사업자등록번호를 1개 이상 입력하세요.")
if len(numbers) > BATCH_LIMIT:
raise ValueError("한 번에 조회할 수 있는 사업자등록번호는 100개까지입니다.")
return {"b_no": numbers}
def build_validate_business(**kwargs: Any) -> dict[str, str]:
p_nm = normalize_validate_text(kwargs.get("p_nm"), "p_nm", required=True)
business = {
"b_no": normalize_business_number(kwargs.get("b_no")),
"start_dt": normalize_start_date(kwargs.get("start_dt")),
"p_nm": p_nm,
}
for key in ("p_nm2", "b_nm", "b_sector", "b_type", "b_adr"):
value = normalize_validate_text(kwargs.get(key), key)
if value:
business[key] = value
corp_no = normalize_corp_no(kwargs.get("corp_no"))
if corp_no:
business["corp_no"] = corp_no
return business
def build_validate_payload(businesses: list[dict[str, Any]]) -> dict[str, list[dict[str, str]]]:
if not businesses:
raise ValueError("진위확인 대상 businesses를 1개 이상 입력하세요.")
if len(businesses) > BATCH_LIMIT:
raise ValueError("한 번에 진위확인할 수 있는 사업자는 100개까지입니다.")
return {"businesses": [build_validate_business(**business) for business in businesses]}
def read_json_response(request: urllib.request.Request) -> dict[str, Any]:
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
try:
payload = json.loads(body)
except json.JSONDecodeError:
payload = None
if isinstance(payload, dict) and payload.get("message"):
raise ApiError(str(payload["message"]), status_code=error.code, url=getattr(error, "url", None)) from error
raise ApiError(f"NTS business proxy request failed with HTTP {error.code}", status_code=error.code, url=getattr(error, "url", None)) from error
except urllib.error.URLError as error:
raise ApiError(f"NTS business proxy request failed: {error.reason}") from error
def _post_json(path: str, payload: dict[str, Any], *, base_url: str | None = None, read_json: Any = read_json_response) -> dict[str, Any]:
resolved_base_url = resolve_proxy_base_url(base_url)
request = urllib.request.Request(
f"{resolved_base_url}{path}",
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": "k-skill-nts-business-registration/1.0",
},
method="POST",
)
return read_json(request)
def query_status(business_numbers: list[Any], *, base_url: str | None = None, read_json: Any = read_json_response) -> dict[str, Any]:
return _post_json("/v1/nts-business/status", build_status_payload(business_numbers), base_url=base_url, read_json=read_json)
def validate_businesses(businesses: list[dict[str, Any]], *, base_url: str | None = None, read_json: Any = read_json_response) -> dict[str, Any]:
return _post_json("/v1/nts-business/validate", build_validate_payload(businesses), base_url=base_url, read_json=read_json)
def _parse_business_json(value: str) -> dict[str, Any]:
payload = json.loads(value)
if not isinstance(payload, dict):
raise argparse.ArgumentTypeError("business JSON must be an object")
return payload
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="NTS business registration status/authenticity helper")
subparsers = parser.add_subparsers(dest="command", required=True)
status = subparsers.add_parser("status", help="사업자등록번호 상태조회")
status.add_argument("--b-no", action="append", required=True, help="사업자등록번호(10자리; 하이픈 허용). 여러 번 지정 가능")
status.add_argument("--proxy-base-url")
validate = subparsers.add_parser("validate", help="사업자등록정보 진위확인")
validate.add_argument("--business-json", action="append", type=_parse_business_json, required=True, help='예: {"b_no":"1234567890","start_dt":"20200101","p_nm":"홍길동"}')
validate.add_argument("--proxy-base-url")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
if args.command == "status":
print(json.dumps(query_status(args.b_no, base_url=args.proxy_base_url), ensure_ascii=False, indent=2))
return 0
if args.command == "validate":
print(json.dumps(validate_businesses(args.business_json, base_url=args.proxy_base_url), ensure_ascii=False, indent=2))
return 0
except (ValueError, ApiError) as error:
print(json.dumps({"error": str(error)}, ensure_ascii=False, indent=2), file=sys.stderr)
return 1
return 1
if __name__ == "__main__":
raise SystemExit(main())

259
scripts/seoul_density.py Normal file
View 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())

View file

@ -1075,6 +1075,23 @@ test("daiso-product-search docs record the shipped feature and official sources"
assert.match(sources, /https:\/\/www\.daisomall\.co\.kr\/api\/pd\/pdh\/selStrPkupStck/);
});
test("repository docs advertise the gangnamunni-clinic-search skill across install surfaces", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const sources = read(path.join("docs", "sources.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "gangnamunni-clinic-search.md");
const skillPath = path.join(repoRoot, "gangnamunni-clinic-search", "SKILL.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/gangnamunni-clinic-search.md to exist");
assert.ok(fs.existsSync(skillPath), "expected gangnamunni-clinic-search/SKILL.md to exist");
assert.match(readme, /\| 강남언니 병원 조회 \| `gangnamunni-clinic-search` \|/);
assert.match(readme, /\[강남언니 병원 조회 가이드\]\(docs\/features\/gangnamunni-clinic-search\.md\)/);
assert.match(install, /--skill gangnamunni-clinic-search/);
assert.match(install, /npm install -g .*gangnamunni-clinic-search/);
assert.match(sources, /강남언니 공개 검색: https:\/\/www\.gangnamunni\.com\/search\?q=<keyword>/);
assert.match(sources, /강남언니 공개 병원 페이지: https:\/\/www\.gangnamunni\.com\/hospitals\/<id>/);
});
test("repository docs advertise the market-kurly-search skill across the documented surfaces", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));

View file

@ -0,0 +1,137 @@
import json
import importlib.util
from pathlib import Path
import unittest
import urllib.error
from scripts.nts_business_registration import (
ApiError,
build_status_payload,
build_validate_business,
normalize_business_number,
normalize_start_date,
query_status,
resolve_proxy_base_url,
validate_businesses,
)
class NtsBusinessNormalizationTest(unittest.TestCase):
def test_normalize_business_number_keeps_ten_digits_only(self):
self.assertEqual(normalize_business_number("123-45-67890"), "1234567890")
with self.assertRaisesRegex(ValueError, "사업자등록번호"):
normalize_business_number("123")
def test_normalize_start_date_accepts_common_date_separators(self):
self.assertEqual(normalize_start_date("2020-01-31"), "20200131")
self.assertEqual(normalize_start_date("2020.01.31"), "20200131")
with self.assertRaisesRegex(ValueError, "개업일자"):
normalize_start_date("2020-13-01")
def test_build_status_payload_limits_batch_size(self):
self.assertEqual(build_status_payload(["123-45-67890"]), {"b_no": ["1234567890"]})
with self.assertRaisesRegex(ValueError, "100개"):
build_status_payload([f"{index:010d}" for index in range(101)])
def test_build_validate_business_trims_optional_fields(self):
business = build_validate_business(
b_no="123-45-67890",
start_dt="2020-01-31",
p_nm=" 홍길동 ",
b_nm="테스트상사",
corp_no="110111-1234567",
p_nm2="",
)
self.assertEqual(
business,
{
"b_no": "1234567890",
"start_dt": "20200131",
"p_nm": "홍길동",
"b_nm": "테스트상사",
"corp_no": "1101111234567",
},
)
def test_build_validate_business_rejects_malformed_or_oversized_optional_fields(self):
base = {"b_no": "1234567890", "start_dt": "20200101", "p_nm": "홍길동"}
with self.assertRaisesRegex(ValueError, "corp_no"):
build_validate_business(**base, corp_no="123")
with self.assertRaisesRegex(ValueError, "p_nm"):
build_validate_business(b_no="1234567890", start_dt="20200101", p_nm="" * 31)
with self.assertRaisesRegex(ValueError, "b_adr"):
build_validate_business(**base, b_adr="" * 501)
def test_skill_local_helper_matches_runtime_validation_behavior(self):
helper_path = Path(__file__).resolve().parents[1] / "nts-business-registration" / "scripts" / "nts_business_registration.py"
spec = importlib.util.spec_from_file_location("skill_local_nts_business_registration", helper_path)
self.assertIsNotNone(spec)
self.assertIsNotNone(spec.loader)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
self.assertEqual(
module.build_validate_business(
b_no="123-45-67890",
start_dt="2020.01.31",
p_nm="홍길동",
corp_no="110111-1234567",
),
{
"b_no": "1234567890",
"start_dt": "20200131",
"p_nm": "홍길동",
"corp_no": "1101111234567",
},
)
with self.assertRaisesRegex(ValueError, "corp_no"):
module.build_validate_business(b_no="1234567890", start_dt="20200101", p_nm="홍길동", corp_no="abc")
class NtsBusinessProxyTest(unittest.TestCase):
def test_query_status_posts_to_proxy_route(self):
captured = {}
def fake_read_json(request):
captured["url"] = request.full_url
captured["data"] = json.loads(request.data.decode("utf-8"))
captured["method"] = request.get_method()
return {"data": [{"b_no": "1234567890", "b_stt": "계속사업자"}]}
payload = query_status(["123-45-67890"], base_url="https://proxy.example.com", read_json=fake_read_json)
self.assertEqual(payload["data"][0]["b_stt"], "계속사업자")
self.assertEqual(captured["url"], "https://proxy.example.com/v1/nts-business/status")
self.assertEqual(captured["data"], {"b_no": ["1234567890"]})
self.assertEqual(captured["method"], "POST")
def test_validate_businesses_posts_to_proxy_route(self):
captured = {}
def fake_read_json(request):
captured["url"] = request.full_url
captured["data"] = json.loads(request.data.decode("utf-8"))
return {"data": [{"valid": "01"}]}
payload = validate_businesses(
[{"b_no": "1234567890", "start_dt": "20200101", "p_nm": "홍길동"}],
base_url="https://proxy.example.com/",
read_json=fake_read_json,
)
self.assertEqual(payload["data"][0]["valid"], "01")
self.assertEqual(captured["url"], "https://proxy.example.com/v1/nts-business/validate")
self.assertEqual(captured["data"], {"businesses": [{"b_no": "1234567890", "start_dt": "20200101", "p_nm": "홍길동"}]})
def test_resolve_proxy_base_url_defaults_to_hosted_proxy(self):
self.assertEqual(resolve_proxy_base_url(None, env={}), "https://k-skill-proxy.nomadamas.org")
self.assertEqual(resolve_proxy_base_url(None, env={"KSKILL_PROXY_BASE_URL": "https://proxy.example.com/"}), "https://proxy.example.com")
with self.assertRaisesRegex(ValueError, "KSKILL_PROXY_BASE_URL"):
resolve_proxy_base_url(None, env={"KSKILL_PROXY_BASE_URL": "off"})
if __name__ == "__main__":
unittest.main()

View 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()

View file

@ -26,7 +26,27 @@ import time
from datetime import datetime
from typing import Any
import httpx
try:
import httpx
except ModuleNotFoundError: # pragma: no cover - depends on user environment
httpx = None
class MissingHttpxError(RuntimeError):
"""Raised when the optional httpx runtime dependency is unavailable."""
def _require_httpx():
if httpx is None:
raise MissingHttpxError(
"Python package 'httpx' is required. Install it with: python3 -m pip install httpx"
)
return httpx
HTTPX_HTTP_ERROR = (
getattr(httpx, "HTTPError", MissingHttpxError) if httpx else MissingHttpxError
)
# ── URL Parsing ───────────────────────────────────────────────────────────────
@ -98,7 +118,8 @@ INTERPARK_BASE = "https://api-ticketfront.interpark.com"
class Yes24Client:
def __init__(self) -> None:
self.http = httpx.Client(
http = _require_httpx()
self.http = http.Client(
headers=HEADERS_YES24, timeout=20, follow_redirects=True
)
@ -227,7 +248,8 @@ class Yes24Client:
class InterparkClient:
def __init__(self) -> None:
self.http = httpx.Client(
http = _require_httpx()
self.http = http.Client(
headers=HEADERS_INTERPARK, timeout=20, follow_redirects=True
)
@ -332,6 +354,7 @@ def cmd_seats(args: argparse.Namespace) -> int:
def cmd_health(args: argparse.Namespace) -> int:
http = _require_httpx()
results: dict = {}
for name, url in [
("yes24",
@ -341,12 +364,12 @@ def cmd_health(args: argparse.Namespace) -> int:
]:
try:
if name == "yes24":
r = httpx.post(url, headers=HEADERS_YES24,
r = http.post(url, headers=HEADERS_YES24,
data={"pGetMode": "days", "pIdPerf": "0",
"pPerfMonth": "2000-01", "pIdCode": "",
"pIsMania": "0"}, timeout=10)
else:
r = httpx.get(url, headers=HEADERS_INTERPARK,
r = http.get(url, headers=HEADERS_INTERPARK,
params={"goodsCode": "00000000",
"isBookableDate": "true",
"page": "1", "pageSize": "1",
@ -395,7 +418,10 @@ def main(argv: list[str] | None = None) -> int:
except ValueError as e:
print(f"error: {e}", file=sys.stderr)
return 2
except httpx.HTTPError as e:
except MissingHttpxError as e:
print(f"dependency error: {e}", file=sys.stderr)
return 4
except HTTPX_HTTP_ERROR as e:
print(f"http error: {e}", file=sys.stderr)
return 3

121
seoul-density/SKILL.md Normal file
View 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` 딕셔너리만 갱신한다.

View file

@ -26,7 +26,27 @@ import time
from datetime import datetime
from typing import Any
import httpx
try:
import httpx
except ModuleNotFoundError: # pragma: no cover - depends on user environment
httpx = None
class MissingHttpxError(RuntimeError):
"""Raised when the optional httpx runtime dependency is unavailable."""
def _require_httpx():
if httpx is None:
raise MissingHttpxError(
"Python package 'httpx' is required. Install it with: python3 -m pip install httpx"
)
return httpx
HTTPX_HTTP_ERROR = (
getattr(httpx, "HTTPError", MissingHttpxError) if httpx else MissingHttpxError
)
# ── URL Parsing ───────────────────────────────────────────────────────────────
@ -98,7 +118,8 @@ INTERPARK_BASE = "https://api-ticketfront.interpark.com"
class Yes24Client:
def __init__(self) -> None:
self.http = httpx.Client(
http = _require_httpx()
self.http = http.Client(
headers=HEADERS_YES24, timeout=20, follow_redirects=True
)
@ -227,7 +248,8 @@ class Yes24Client:
class InterparkClient:
def __init__(self) -> None:
self.http = httpx.Client(
http = _require_httpx()
self.http = http.Client(
headers=HEADERS_INTERPARK, timeout=20, follow_redirects=True
)
@ -332,6 +354,7 @@ def cmd_seats(args: argparse.Namespace) -> int:
def cmd_health(args: argparse.Namespace) -> int:
http = _require_httpx()
results: dict = {}
for name, url in [
("yes24",
@ -341,12 +364,12 @@ def cmd_health(args: argparse.Namespace) -> int:
]:
try:
if name == "yes24":
r = httpx.post(url, headers=HEADERS_YES24,
r = http.post(url, headers=HEADERS_YES24,
data={"pGetMode": "days", "pIdPerf": "0",
"pPerfMonth": "2000-01", "pIdCode": "",
"pIsMania": "0"}, timeout=10)
else:
r = httpx.get(url, headers=HEADERS_INTERPARK,
r = http.get(url, headers=HEADERS_INTERPARK,
params={"goodsCode": "00000000",
"isBookableDate": "true",
"page": "1", "pageSize": "1",
@ -395,7 +418,10 @@ def main(argv: list[str] | None = None) -> int:
except ValueError as e:
print(f"error: {e}", file=sys.stderr)
return 2
except httpx.HTTPError as e:
except MissingHttpxError as e:
print(f"dependency error: {e}", file=sys.stderr)
return 4
except HTTPX_HTTP_ERROR as e:
print(f"http error: {e}", file=sys.stderr)
return 3