mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Merge branch 'dev' into feature/#228
This commit is contained in:
commit
8baf3adc23
37 changed files with 3164 additions and 22 deletions
5
.changeset/gangnamunni-clinic-search.md
Normal file
5
.changeset/gangnamunni-clinic-search.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"gangnamunni-clinic-search": minor
|
||||
---
|
||||
|
||||
Add Gangnam Unni public clinic search skill and package.
|
||||
5
.changeset/nts-business-registration.md
Normal file
5
.changeset/nts-business-registration.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"k-skill-proxy": patch
|
||||
---
|
||||
|
||||
Add National Tax Service business registration status and authenticity proxy routes.
|
||||
5
.changeset/seoul-density.md
Normal file
5
.changeset/seoul-density.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"k-skill-proxy": minor
|
||||
---
|
||||
|
||||
Add `/v1/seoul-density/citydata` route that proxies the Seoul Open Data realtime hotspot crowd-level API (`citydata_ppltn`) using the server-side `SEOUL_OPEN_API_KEY`.
|
||||
21
.github/workflows/release-python.yml
vendored
21
.github/workflows/release-python.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
32
docs/features/gangnamunni-clinic-search.md
Normal file
32
docs/features/gangnamunni-clinic-search.md
Normal 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 페이지는 실패 모드로 처리합니다.
|
||||
- 의료 판단이나 병원 선택 보증을 대신하지 않습니다.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
50
docs/features/nts-business-registration.md
Normal file
50
docs/features/nts-business-registration.md
Normal 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>
|
||||
88
docs/features/seoul-density.md
Normal file
88
docs/features/seoul-density.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# 서울 실시간 혼잡도 조회 가이드
|
||||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
- 서울 주요 121개 핫스팟의 실시간 혼잡도 단계(여유 / 보통 / 약간 붐빔 / 붐빔) 확인
|
||||
- KT·SKT 통신 신호 기반 추정 인구 범위(`AREA_PPLTN_MIN ~ AREA_PPLTN_MAX`) 확인
|
||||
- 기준 시각(`PPLTN_TIME`)과 혼잡도 메시지(`AREA_CONGEST_MSG`) 같이 확인
|
||||
- 별도 사용자 `SEOUL_OPEN_API_KEY` 없이 `k-skill-proxy` 로 조회
|
||||
|
||||
## 먼저 필요한 것
|
||||
|
||||
- [공통 설정 가이드](../setup.md) 확인
|
||||
|
||||
## 기본 경로
|
||||
|
||||
기본적으로 `https://k-skill-proxy.nomadamas.org/v1/seoul-density/citydata` 로 요청한다.
|
||||
|
||||
사용자는 별도 서울 열린데이터 광장 OpenAPI key 를 직접 발급받을 필요는 없다. upstream key 는 proxy 서버에서만 `SEOUL_OPEN_API_KEY` 로 관리한다.
|
||||
|
||||
`KSKILL_PROXY_BASE_URL` 환경변수가 있으면 그 값을 사용하고, 비우면 기본 hosted `https://k-skill-proxy.nomadamas.org` 를 쓴다.
|
||||
|
||||
## 입력값
|
||||
|
||||
- `area` — 지원 장소명 (예: `강남역`, `홍대 관광특구`, `여의도한강공원`)
|
||||
|
||||
지원 장소 전체 목록은 `seoul-density/SKILL.md` 의 `AREAS` 카테고리 또는 다음 명령으로 확인한다:
|
||||
|
||||
```bash
|
||||
python3 seoul-density/scripts/seoul_density.py list
|
||||
```
|
||||
|
||||
## 기본 흐름
|
||||
|
||||
1. client/skill 은 기본 hosted path 또는 `KSKILL_PROXY_BASE_URL` 아래 `/v1/seoul-density/citydata` endpoint 를 호출한다.
|
||||
2. proxy 는 서울 열린데이터 광장 `citydata_ppltn/1/1/{area}` 를 `SEOUL_OPEN_API_KEY` 와 함께 호출한다.
|
||||
3. 응답을 그대로 돌려주며, `proxy.cache.hit` 메타데이터를 추가한다.
|
||||
|
||||
## 예시
|
||||
|
||||
```bash
|
||||
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
|
||||
curl -fsS --get "${BASE}/v1/seoul-density/citydata" \
|
||||
--data-urlencode 'area=강남역'
|
||||
```
|
||||
|
||||
스킬 CLI 사용 예시:
|
||||
|
||||
```bash
|
||||
python3 seoul-density/scripts/seoul_density.py query "강남역"
|
||||
```
|
||||
|
||||
예상 응답 (요약):
|
||||
|
||||
```json
|
||||
{
|
||||
"SeoulRtd.citydata_ppltn": [
|
||||
{
|
||||
"AREA_NM": "강남역",
|
||||
"AREA_CONGEST_LVL": "약간 붐빔",
|
||||
"AREA_PPLTN_MIN": "24000",
|
||||
"AREA_PPLTN_MAX": "26000",
|
||||
"PPLTN_TIME": "2026-05-14 09:30",
|
||||
"AREA_CONGEST_MSG": "사람이 몰려있을 수 있어요"
|
||||
}
|
||||
],
|
||||
"RESULT": { "RESULT.CODE": "INFO-000" }
|
||||
}
|
||||
```
|
||||
|
||||
## fallback / 대체 흐름
|
||||
|
||||
- `KSKILL_PROXY_BASE_URL` 을 별도로 넣으면 해당 proxy 를 우선 사용한다.
|
||||
- 기본 hosted path 는 `https://k-skill-proxy.nomadamas.org/v1/seoul-density/citydata` 이다.
|
||||
- self-host 운영자는 서버 쪽에만 `SEOUL_OPEN_API_KEY` 를 넣는다 (사용자 쪽에는 키가 필요 없다).
|
||||
|
||||
## 주의할 점
|
||||
|
||||
- 인구 수치는 실제값이 아닌 **추계치** (KT·SKT 통신 신호 데이터 기반).
|
||||
- 데이터는 호출 시점 기준 **약 15분 전** 값이며 5분 주기로 갱신된다.
|
||||
- 새벽 01~05시는 실시간 데이터가 제공되지 않을 수 있다.
|
||||
- 일일 호출 할당량 초과 시 다음 날 재시도해야 한다.
|
||||
- 지원하지 않는 장소명을 넣으면 빈 응답이 돌아오므로 스킬의 `match` 서브커맨드로 후보를 먼저 확인한다.
|
||||
|
||||
## 참고 표면
|
||||
|
||||
- 공식 API 안내: `https://data.seoul.go.kr/dataList/OA-21778/A/1/datasetView.do`
|
||||
- 서울 열린데이터 광장: `https://data.seoul.go.kr`
|
||||
- proxy 운영 안내: [k-skill 프록시 서버 가이드](k-skill-proxy.md)
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ KAKAO_REST_API_KEY=replace-me
|
|||
KSKILL_PROXY_BASE_URL=
|
||||
```
|
||||
|
||||
서울 지하철 도착정보와 한국 날씨 조회는 `KSKILL_PROXY_BASE_URL` 이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, KOSIS 일반 조회, Kakao Local geocoding, 의약품 안전 체크, 식품 안전 체크도 기본 hosted proxy를 쓴다. 생활쓰레기 배출정보는 `k-skill-proxy`의 `/v1/household-waste/info` 라우트를 거쳐 `serviceKey`만 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다.
|
||||
서울 지하철 도착정보, 서울 실시간 혼잡도 조회, 한국 날씨 조회는 `KSKILL_PROXY_BASE_URL` 이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, KOSIS 일반 조회, Kakao Local geocoding, 의약품 안전 체크, 식품 안전 체크도 기본 hosted proxy를 쓴다. 생활쓰레기 배출정보는 `k-skill-proxy`의 `/v1/household-waste/info` 라우트를 거쳐 `serviceKey`만 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다.
|
||||
|
||||
## Missing secret handling policy
|
||||
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ bash scripts/check-setup.sh
|
|||
| 한국 주식 정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KRX_API_KEY`) |
|
||||
| 근처 가장 싼 주유소 찾기 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
|
||||
| 서울 지하철 도착정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `SEOUL_OPEN_API_KEY`) |
|
||||
| 서울 실시간 혼잡도 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `SEOUL_OPEN_API_KEY`) |
|
||||
| 한국 날씨 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KMA_OPEN_API_KEY`) |
|
||||
| 사용자 위치 미세먼지 조회 | `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY` |
|
||||
| 한강 수위 정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
|
||||
|
|
@ -103,6 +104,7 @@ bash scripts/check-setup.sh
|
|||
- [시외버스 예매 가이드](features/intercity-bus-booking.md)
|
||||
- [자연휴양림 빈 객실 조회 가이드](features/foresttrip-vacancy.md)
|
||||
- [서울 지하철 도착정보 가이드](features/seoul-subway-arrival.md)
|
||||
- [서울 실시간 혼잡도 가이드](features/seoul-density.md)
|
||||
- [한국 날씨 조회 가이드](features/korea-weather.md)
|
||||
- [사용자 위치 미세먼지 조회 가이드](features/fine-dust-location.md)
|
||||
- [한강 수위 정보 가이드](features/han-river-water-level.md)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
123
gangnamunni-clinic-search/SKILL.md
Normal file
123
gangnamunni-clinic-search/SKILL.md
Normal 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는 사용하지 않는다.
|
||||
- 의료 조언이나 병원 추천 보증이 아니라 공개 후보 정리로만 답한다.
|
||||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
125
nts-business-registration/SKILL.md
Normal file
125
nts-business-registration/SKILL.md
Normal 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 키는 사용자에게 요구하지 않고 프록시 서버에만 둔다는 점을 지켰다.
|
||||
211
nts-business-registration/scripts/nts_business_registration.py
Normal file
211
nts-business-registration/scripts/nts_business_registration.py
Normal 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
14
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
35
packages/gangnamunni-clinic-search/README.md
Normal file
35
packages/gangnamunni-clinic-search/README.md
Normal 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.
|
||||
35
packages/gangnamunni-clinic-search/package.json
Normal file
35
packages/gangnamunni-clinic-search/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
45
packages/gangnamunni-clinic-search/src/cli.js
Executable file
45
packages/gangnamunni-clinic-search/src/cli.js
Executable 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 }
|
||||
188
packages/gangnamunni-clinic-search/src/index.js
Normal file
188
packages/gangnamunni-clinic-search/src/index.js
Normal 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(/"/g, '"')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/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
|
||||
}
|
||||
195
packages/gangnamunni-clinic-search/test/index.test.js
Normal file
195
packages/gangnamunni-clinic-search/test/index.test.js
Normal 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 " Clinic & 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 " Clinic & Care")
|
||||
})
|
||||
|
||||
test("parseNextData falls back to entity-decoded legacy payloads", () => {
|
||||
const html = `<script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"keyword":"강남"}}}</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/)
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
206
packages/k-skill-proxy/src/nts-business.js
Normal file
206
packages/k-skill-proxy/src/nts-business.js
Normal 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
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
211
scripts/nts_business_registration.py
Normal file
211
scripts/nts_business_registration.py
Normal 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
259
scripts/seoul_density.py
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
"""Single-entrypoint CLI for the seoul-density skill.
|
||||
|
||||
All skill operations route through `python3 seoul-density/scripts/seoul_density.py <subcommand>`
|
||||
so users only have to approve one Bash pattern on first use.
|
||||
|
||||
Subcommands:
|
||||
list — print supported area names grouped by category
|
||||
match <keyword> — fuzzy-match a user keyword to a supported area name
|
||||
query <area-name> [--json] — fetch and summarize real-time density for the area
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import difflib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
from typing import Any
|
||||
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if reconfigure is not None:
|
||||
try:
|
||||
reconfigure(encoding="utf-8")
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
AREAS: dict[str, list[str]] = {
|
||||
"고궁·문화유산": [
|
||||
"경복궁", "광화문·덕수궁", "보신각", "서울 암사동 유적", "창덕궁·종묘",
|
||||
],
|
||||
"관광특구": [
|
||||
"강남 MICE 관광특구", "동대문 관광특구", "명동 관광특구", "이태원 관광특구",
|
||||
"잠실 관광특구", "종로·청계 관광특구", "홍대 관광특구",
|
||||
],
|
||||
"공원": [
|
||||
"강서한강공원", "고척돔", "광나루한강공원", "광화문광장",
|
||||
"국립중앙박물관·용산가족공원", "난지한강공원", "남산공원", "노들섬",
|
||||
"뚝섬한강공원", "망원한강공원", "반포한강공원", "보라매공원",
|
||||
"북서울꿈의숲", "서대문독립공원", "서리풀공원·몽마르뜨공원", "서울대공원",
|
||||
"서울숲공원", "송현녹지광장", "아차산", "안양천", "양화한강공원",
|
||||
"어린이대공원", "여의도한강공원", "여의서로", "올림픽공원", "월드컵공원",
|
||||
"응봉산", "이촌한강공원", "잠실종합운동장", "잠실한강공원", "잠원한강공원",
|
||||
"청계산", "홍제폭포",
|
||||
],
|
||||
"발달상권": [
|
||||
"가락시장", "가로수길", "광장(전통)시장", "김포공항", "남대문시장", "노량진",
|
||||
"덕수궁길·정동길", "북창동 먹자골목", "북촌한옥마을", "서촌", "성수카페거리",
|
||||
"송리단길·호수단길", "신촌 스타광장", "압구정로데오거리", "여의도", "연남동",
|
||||
"영등포 타임스퀘어", "용리단길", "이태원 앤틱가구거리", "익선동", "인사동",
|
||||
"잠실롯데타워·석촌호수", "창동 신경제 중심지", "청담동 명품거리",
|
||||
"청량리 제기동 일대 전통시장", "해방촌·경리단길", "DDP(동대문디자인플라자)",
|
||||
"DMC(디지털미디어시티)",
|
||||
],
|
||||
"인구밀집지역": [
|
||||
"가산디지털단지역", "강남역", "건대입구역", "고덕역", "고속터미널역", "교대역",
|
||||
"구로디지털단지역", "구로역", "군자역", "대림역", "동대문역", "뚝섬역",
|
||||
"미아사거리역", "발산역", "사당역", "삼각지역", "서울대입구역",
|
||||
"서울식물원·마곡나루역", "서울역", "성신여대입구역", "선릉역", "시의회 앞",
|
||||
"수유역", "신논현역·논현역", "신도림역", "신림역", "신촌·이대역", "쌍문역",
|
||||
"신정네거리역", "역삼역", "연신내역", "양재역", "왕십리역", "용산역",
|
||||
"오목교역·목동운동장", "잠실새내역", "잠실역", "장지역", "장한평역", "천호역",
|
||||
"총신대입구(이수)역", "충정로역", "합정역", "혜화역", "홍대입구역(2호선)",
|
||||
"회기역",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
TIMEOUT_SEC = 10
|
||||
PROXY_BASE_URL_NAME = "KSKILL_PROXY_BASE_URL"
|
||||
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
|
||||
|
||||
|
||||
def all_areas() -> list[str]:
|
||||
return [name for group in AREAS.values() for name in group]
|
||||
|
||||
|
||||
def cmd_list(args: argparse.Namespace) -> int:
|
||||
if args.json:
|
||||
json.dump(AREAS, sys.stdout, ensure_ascii=False, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
return 0
|
||||
for category, names in AREAS.items():
|
||||
print(f"## {category} ({len(names)}곳)")
|
||||
print(", ".join(names))
|
||||
print()
|
||||
return 0
|
||||
|
||||
|
||||
def _normalize(text: str) -> str:
|
||||
"""Strip whitespace and common location suffixes for loose matching."""
|
||||
cleaned = "".join(ch for ch in text if not ch.isspace())
|
||||
for suffix in ("관광특구", "한강공원", "공원", "시장", "역", "거리", "광장"):
|
||||
if cleaned.endswith(suffix) and len(cleaned) > len(suffix):
|
||||
cleaned = cleaned[: -len(suffix)]
|
||||
break
|
||||
return cleaned
|
||||
|
||||
|
||||
def fuzzy_match(keyword: str, limit: int = 5) -> list[str]:
|
||||
names = all_areas()
|
||||
keyword = keyword.strip()
|
||||
if not keyword:
|
||||
return []
|
||||
|
||||
exact = [n for n in names if keyword in n]
|
||||
if exact:
|
||||
return exact[:limit]
|
||||
|
||||
contained = [n for n in names if n in keyword]
|
||||
if contained:
|
||||
return contained[:limit]
|
||||
|
||||
norm_kw = _normalize(keyword)
|
||||
if norm_kw:
|
||||
loose = [n for n in names if norm_kw and (norm_kw in _normalize(n) or _normalize(n) in norm_kw)]
|
||||
if loose:
|
||||
return loose[:limit]
|
||||
|
||||
return difflib.get_close_matches(keyword, names, n=limit, cutoff=0.3)
|
||||
|
||||
|
||||
def cmd_match(args: argparse.Namespace) -> int:
|
||||
matches = fuzzy_match(args.keyword, limit=args.limit)
|
||||
if not matches:
|
||||
print(f"'{args.keyword}'와 일치하는 지원 장소가 없습니다.", file=sys.stderr)
|
||||
print("'python3 seoul-density/scripts/seoul_density.py list' 로 전체 목록을 확인하세요.", file=sys.stderr)
|
||||
return 1
|
||||
if args.json:
|
||||
json.dump(matches, sys.stdout, ensure_ascii=False)
|
||||
sys.stdout.write("\n")
|
||||
else:
|
||||
for name in matches:
|
||||
print(name)
|
||||
return 0
|
||||
|
||||
|
||||
def get_proxy_base_url() -> str:
|
||||
value = os.environ.get(PROXY_BASE_URL_NAME)
|
||||
if value and value != "replace-me":
|
||||
return value.rstrip("/")
|
||||
return DEFAULT_PROXY_BASE_URL
|
||||
|
||||
|
||||
def fetch_density_via_proxy(area: str) -> dict[str, Any]:
|
||||
base_url = get_proxy_base_url()
|
||||
query = urllib.parse.urlencode({"area": area})
|
||||
url = f"{base_url}/v1/seoul-density/citydata?{query}"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "k-skill/seoul-density"})
|
||||
with urllib.request.urlopen(req, timeout=TIMEOUT_SEC) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def summarize(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
result = payload.get("RESULT") or {}
|
||||
code = result.get("RESULT.CODE")
|
||||
message = result.get("RESULT.MESSAGE", "")
|
||||
if code and code != "INFO-000":
|
||||
raise RuntimeError(f"API 오류: {code} {message}".strip())
|
||||
|
||||
rows = payload.get("SeoulRtd.citydata_ppltn") or []
|
||||
if not rows:
|
||||
raise RuntimeError("인구 데이터가 없습니다. 장소명을 'match' 서브커맨드로 확인하세요.")
|
||||
|
||||
row = rows[0]
|
||||
return {
|
||||
"area": row.get("AREA_NM"),
|
||||
"congestion_level": row.get("AREA_CONGEST_LVL"),
|
||||
"population_min": row.get("AREA_PPLTN_MIN"),
|
||||
"population_max": row.get("AREA_PPLTN_MAX"),
|
||||
"as_of": row.get("PPLTN_TIME"),
|
||||
"message": row.get("AREA_CONGEST_MSG"),
|
||||
}
|
||||
|
||||
|
||||
def cmd_query(args: argparse.Namespace) -> int:
|
||||
area = args.area.strip()
|
||||
if area not in all_areas():
|
||||
suggestions = fuzzy_match(area, limit=3)
|
||||
if len(suggestions) == 1 and getattr(args, "auto", True):
|
||||
print(f"'{area}' → '{suggestions[0]}' 로 자동 매칭", file=sys.stderr)
|
||||
area = suggestions[0]
|
||||
else:
|
||||
hint = (
|
||||
f" 가까운 후보: {', '.join(suggestions)}" if suggestions else ""
|
||||
)
|
||||
print(f"지원하지 않는 장소: {area}{hint}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
payload = fetch_density_via_proxy(area)
|
||||
summary = summarize(payload)
|
||||
except urllib.error.HTTPError as exc:
|
||||
print(f"API HTTP 오류: {exc.code} {exc.reason}", file=sys.stderr)
|
||||
return 1
|
||||
except urllib.error.URLError as exc:
|
||||
print(f"API 연결 실패: {exc.reason}", file=sys.stderr)
|
||||
return 1
|
||||
except (RuntimeError, json.JSONDecodeError) as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.json:
|
||||
json.dump(summary, sys.stdout, ensure_ascii=False, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
return 0
|
||||
|
||||
print(f"장소: {summary['area']}")
|
||||
print(f"혼잡도: {summary['congestion_level']}")
|
||||
print(f"인구 추정: {summary['population_min']}~{summary['population_max']}명")
|
||||
print(f"기준 시각: {summary['as_of'] or '알 수 없음'}")
|
||||
print(f"상황: {summary['message']}")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="seoul_density",
|
||||
description="서울 실시간 도시데이터(혼잡도/인구) 단일 진입점 CLI",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p_list = sub.add_parser("list", help="지원 장소 목록 출력")
|
||||
p_list.add_argument("--json", action="store_true")
|
||||
p_list.set_defaults(func=cmd_list)
|
||||
|
||||
p_match = sub.add_parser("match", help="키워드 → 지원 장소명 매칭")
|
||||
p_match.add_argument("keyword")
|
||||
p_match.add_argument("--limit", type=int, default=5)
|
||||
p_match.add_argument("--json", action="store_true")
|
||||
p_match.set_defaults(func=cmd_match)
|
||||
|
||||
p_query = sub.add_parser("query", help="장소 혼잡도 조회")
|
||||
p_query.add_argument("area", help="지원 장소명 (목록은 'list' 참조)")
|
||||
p_query.add_argument("--json", action="store_true")
|
||||
p_query.add_argument(
|
||||
"--no-auto",
|
||||
dest="auto",
|
||||
action="store_false",
|
||||
help="후보가 1개뿐이어도 자동 매칭하지 않음",
|
||||
)
|
||||
p_query.set_defaults(func=cmd_query, auto=True)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -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"));
|
||||
|
|
|
|||
137
scripts/test_nts_business_registration.py
Normal file
137
scripts/test_nts_business_registration.py
Normal 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()
|
||||
152
scripts/test_seoul_density.py
Normal file
152
scripts/test_seoul_density.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"""Tests for seoul_density CLI helpers (no network access)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import unittest
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from unittest import mock
|
||||
|
||||
import seoul_density as sd
|
||||
|
||||
|
||||
class FuzzyMatchTests(unittest.TestCase):
|
||||
def test_exact_substring_wins(self) -> None:
|
||||
result = sd.fuzzy_match("강남역")
|
||||
self.assertIn("강남역", result)
|
||||
|
||||
def test_keyword_contained_in_area(self) -> None:
|
||||
result = sd.fuzzy_match("홍대")
|
||||
self.assertTrue(any("홍대" in name for name in result))
|
||||
|
||||
def test_close_match_fallback(self) -> None:
|
||||
result = sd.fuzzy_match("여의도공원")
|
||||
self.assertTrue(result, "close match should return at least one candidate")
|
||||
|
||||
def test_loose_match_strips_역_suffix(self) -> None:
|
||||
result = sd.fuzzy_match("강남")
|
||||
self.assertIn("강남역", result)
|
||||
|
||||
|
||||
class SummarizeTests(unittest.TestCase):
|
||||
def test_ok_payload(self) -> None:
|
||||
payload = {
|
||||
"RESULT": {"RESULT.CODE": "INFO-000", "RESULT.MESSAGE": "OK"},
|
||||
"SeoulRtd.citydata_ppltn": [
|
||||
{
|
||||
"AREA_NM": "강남역",
|
||||
"AREA_CONGEST_LVL": "붐빔",
|
||||
"AREA_PPLTN_MIN": "30000",
|
||||
"AREA_PPLTN_MAX": "32000",
|
||||
"PPLTN_TIME": "2026-05-14 09:30",
|
||||
"AREA_CONGEST_MSG": "평소보다 매우 많은 인파",
|
||||
}
|
||||
],
|
||||
}
|
||||
summary = sd.summarize(payload)
|
||||
self.assertEqual(summary["area"], "강남역")
|
||||
self.assertEqual(summary["congestion_level"], "붐빔")
|
||||
|
||||
def test_api_error_code_raises(self) -> None:
|
||||
payload = {"RESULT": {"RESULT.CODE": "ERROR-300", "RESULT.MESSAGE": "bad key"}}
|
||||
with self.assertRaises(RuntimeError):
|
||||
sd.summarize(payload)
|
||||
|
||||
def test_empty_rows_raises(self) -> None:
|
||||
payload = {"RESULT": {"RESULT.CODE": "INFO-000"}, "SeoulRtd.citydata_ppltn": []}
|
||||
with self.assertRaises(RuntimeError):
|
||||
sd.summarize(payload)
|
||||
|
||||
|
||||
class CLITests(unittest.TestCase):
|
||||
def test_list_json(self) -> None:
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
rc = sd.main(["list", "--json"])
|
||||
self.assertEqual(rc, 0)
|
||||
data = json.loads(buf.getvalue())
|
||||
self.assertIn("관광특구", data)
|
||||
|
||||
def test_match_unknown_keyword(self) -> None:
|
||||
err = io.StringIO()
|
||||
with redirect_stderr(err):
|
||||
rc = sd.main(["match", "절대로_존재하지_않는_장소_xyzzy"])
|
||||
self.assertEqual(rc, 1)
|
||||
|
||||
def test_query_unsupported_area(self) -> None:
|
||||
err = io.StringIO()
|
||||
with redirect_stderr(err):
|
||||
rc = sd.main(["query", "존재하지않는장소xyzzy"])
|
||||
self.assertEqual(rc, 1)
|
||||
|
||||
def test_query_auto_matches_single_candidate(self) -> None:
|
||||
payload = {
|
||||
"RESULT": {"RESULT.CODE": "INFO-000"},
|
||||
"SeoulRtd.citydata_ppltn": [
|
||||
{
|
||||
"AREA_NM": "서울 암사동 유적",
|
||||
"AREA_CONGEST_LVL": "보통",
|
||||
"AREA_PPLTN_MIN": "1000",
|
||||
"AREA_PPLTN_MAX": "1200",
|
||||
"PPLTN_TIME": "2026-05-14 10:00",
|
||||
"AREA_CONGEST_MSG": "평소와 비슷",
|
||||
}
|
||||
],
|
||||
}
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
def fake_proxy(area: str) -> dict:
|
||||
captured["area"] = area
|
||||
return payload
|
||||
|
||||
buf = io.StringIO()
|
||||
err = io.StringIO()
|
||||
with mock.patch.object(sd, "fetch_density_via_proxy", side_effect=fake_proxy), \
|
||||
redirect_stdout(buf), redirect_stderr(err):
|
||||
rc = sd.main(["query", "암사동"])
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(captured.get("area"), "서울 암사동 유적")
|
||||
self.assertIn("자동 매칭", err.getvalue())
|
||||
|
||||
def test_no_auto_disables_single_match(self) -> None:
|
||||
err = io.StringIO()
|
||||
with redirect_stderr(err):
|
||||
rc = sd.main(["query", "암사동", "--no-auto"])
|
||||
self.assertEqual(rc, 1)
|
||||
|
||||
def test_query_happy_path(self) -> None:
|
||||
payload = {
|
||||
"RESULT": {"RESULT.CODE": "INFO-000"},
|
||||
"SeoulRtd.citydata_ppltn": [
|
||||
{
|
||||
"AREA_NM": "강남역",
|
||||
"AREA_CONGEST_LVL": "보통",
|
||||
"AREA_PPLTN_MIN": "10000",
|
||||
"AREA_PPLTN_MAX": "12000",
|
||||
"PPLTN_TIME": "2026-05-14 09:00",
|
||||
"AREA_CONGEST_MSG": "평소와 비슷",
|
||||
}
|
||||
],
|
||||
}
|
||||
buf = io.StringIO()
|
||||
with mock.patch.object(sd, "fetch_density_via_proxy", return_value=payload), \
|
||||
redirect_stdout(buf):
|
||||
rc = sd.main(["query", "강남역", "--json"])
|
||||
self.assertEqual(rc, 0)
|
||||
out = json.loads(buf.getvalue())
|
||||
self.assertEqual(out["congestion_level"], "보통")
|
||||
|
||||
|
||||
class ProxyHelpersTests(unittest.TestCase):
|
||||
def test_proxy_base_url_default(self) -> None:
|
||||
with mock.patch.dict("os.environ", {}, clear=True):
|
||||
self.assertEqual(sd.get_proxy_base_url(), sd.DEFAULT_PROXY_BASE_URL)
|
||||
|
||||
def test_proxy_base_url_custom_strips_trailing_slash(self) -> None:
|
||||
with mock.patch.dict("os.environ", {"KSKILL_PROXY_BASE_URL": "https://example.com/"}, clear=True):
|
||||
self.assertEqual(sd.get_proxy_base_url(), "https://example.com")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -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
121
seoul-density/SKILL.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
---
|
||||
name: seoul-density
|
||||
description: 서울 주요 121개 핫스팟 장소의 실시간 혼잡도와 인구 현황을 조회한다. 지금 강남역이 얼마나 붐비는지, 홍대 인파가 얼마나 되는지 물어볼 때 사용한다.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: utility
|
||||
locale: ko-KR
|
||||
phase: v1
|
||||
---
|
||||
|
||||
# Seoul Density
|
||||
|
||||
## What this skill does
|
||||
|
||||
서울 실시간 도시데이터 API(data.seoul.go.kr)를 호출해 121개 핫스팟의 **현재 혼잡도 단계**(여유 / 보통 / 약간 붐빔 / 붐빔)와 **추정 인구 범위**를 반환한다.
|
||||
|
||||
데이터는 KT·SKT 통신 신호 기반 추계치이며, 5분 주기로 갱신되나 호출 시점 기준 약 15분 전 값이다.
|
||||
|
||||
## When to use
|
||||
|
||||
- "지금 강남역 얼마나 붐벼?"
|
||||
- "홍대 지금 인파 어때?"
|
||||
- "명동 지금 사람 많아?"
|
||||
- "여의도한강공원 지금 여유로워?"
|
||||
|
||||
## Prerequisites
|
||||
|
||||
별도 API 키 발급 없이 그대로 쓸 수 있다. 모든 호출은 **k-skill-proxy 경유**다.
|
||||
|
||||
- 기본 프록시 URL: `https://k-skill-proxy.nomadamas.org` — 프록시 서버가 `SEOUL_OPEN_API_KEY`를 보유하고 있어 사용자는 키 없이 호출만 하면 된다.
|
||||
- `KSKILL_PROXY_BASE_URL` 환경변수로 프록시 주소를 바꿀 수 있다(예: 로컬 개발용 `http://127.0.0.1:4020`).
|
||||
|
||||
## Single entrypoint
|
||||
|
||||
이 스킬의 모든 동작은 **단일 진입점**을 통한다. OS·CWD에 관계없이 동일하게 동작하도록 절대 경로 + Python launcher fallback을 사용한다:
|
||||
|
||||
```bash
|
||||
# macOS / Linux / Git-bash
|
||||
python3 "$SKILL_DIR/scripts/seoul_density.py" <subcommand> [args]
|
||||
|
||||
# Windows (PowerShell): py 런처 또는 python
|
||||
py -3 "$env:SKILL_DIR\scripts\seoul_density.py" <subcommand> [args]
|
||||
```
|
||||
|
||||
`$SKILL_DIR`은 이 SKILL.md가 위치한 디렉토리다(`~/.claude/skills/seoul-density` 또는 레포의 `seoul-density/`). 호출 예시는 아래 Workflow 참조.
|
||||
|
||||
첫 사용 시 `Bash(python3 *seoul_density.py:*)` (또는 PowerShell 환경에서 `PowerShell(py -3 *seoul_density.py*)`) 패턴 한 번만 승인하면 이후 호출은 모두 자동 허용된다. 외부 dependency는 없고 Python 표준 라이브러리만 사용한다.
|
||||
|
||||
|
||||
|
||||
### Subcommands
|
||||
|
||||
| 명령 | 설명 |
|
||||
|------|------|
|
||||
| `list [--json]` | 지원 121개 장소 목록 (카테고리별) |
|
||||
| `match <키워드> [--limit N] [--json]` | 사용자 입력 → 지원 장소명 매칭 |
|
||||
| `query <장소명> [--json]` | 실시간 혼잡도/인구 조회 (사람이 읽는 요약 또는 JSON) |
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. 모호한 입력은 match로 후보 확인 (선택)
|
||||
|
||||
사용자가 "홍대 인파"처럼 모호하게 말하면 먼저 후보를 확인한다.
|
||||
|
||||
```bash
|
||||
python3 "$SKILL_DIR/scripts/seoul_density.py" match "홍대" --json
|
||||
# → ["홍대 관광특구", "홍대입구역(2호선)"]
|
||||
```
|
||||
|
||||
후보가 1개면 바로 `query`로 넘어가도 되고(스크립트가 자동 매칭), 여러 개면 어느 쪽인지 사용자에게 확인한다.
|
||||
|
||||
### 2. 혼잡도 조회
|
||||
|
||||
키워드 1개만 매칭되면 자동으로 보정한다.
|
||||
|
||||
```bash
|
||||
# macOS / Linux / Git-bash
|
||||
python3 "$SKILL_DIR/scripts/seoul_density.py" query "강남역"
|
||||
|
||||
# Windows PowerShell
|
||||
py -3 "$env:SKILL_DIR\scripts\seoul_density.py" query "강남역"
|
||||
```
|
||||
|
||||
출력 예시:
|
||||
|
||||
```
|
||||
장소: 강남역
|
||||
혼잡도: 약간 붐빔
|
||||
인구 추정: 24000~26000명
|
||||
기준 시각: 2026-05-14 09:30
|
||||
상황: 사람이 몰려있을 수 있어요
|
||||
```
|
||||
|
||||
기계적 후처리가 필요하면 `--json` 플래그를 쓴다:
|
||||
|
||||
```bash
|
||||
python3 "$SKILL_DIR/scripts/seoul_density.py" query "강남역" --json
|
||||
```
|
||||
|
||||
자동 매칭을 끄고 싶으면 `--no-auto`를 쓴다.
|
||||
|
||||
## Done when
|
||||
|
||||
- 장소명, 혼잡도 단계, 추정 인구 범위(최소~최대), 기준 시각, 혼잡도 메시지를 사용자에게 전달했다.
|
||||
|
||||
## Failure modes
|
||||
|
||||
| 상황 | 동작 |
|
||||
|------|------|
|
||||
| 프록시 정상 응답 | 별도 키 불필요, 즉시 결과 반환 |
|
||||
| 지원하지 않는 장소명 (`exit 1`) | `match` 결과로 후보 제안 |
|
||||
| 프록시 HTTP/네트워크 오류 (`exit 1`) | stderr에 사유 출력, `KSKILL_PROXY_BASE_URL` 점검 또는 5분 후 재시도 안내 |
|
||||
| 새벽 01~05시 빈 응답 | 실시간 데이터 미제공 시간대임을 안내 |
|
||||
| 일일 할당량 초과 | 다음 날 재시도 안내 |
|
||||
|
||||
## Notes
|
||||
|
||||
- 인구 수치는 실제값이 아닌 **추계치** (KT·SKT 통신 신호 데이터 기반).
|
||||
- 데이터는 호출 시점 기준 **약 15분 전** 값.
|
||||
- 단일 진입점 외에 `curl`, `python3 -c`, `source` 같은 inline 명령을 직접 실행하지 말 것. 그렇게 하면 사용자가 매번 별도 승인을 받아야 한다.
|
||||
- 새 카테고리/장소가 추가되면 `seoul-density/scripts/seoul_density.py`의 `AREAS` 딕셔너리만 갱신한다.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue