mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
real-estate-search: MCP self-host → k-skill-proxy HTTP 전환
사용자가 직접 real-estate-mcp를 clone/self-host하는 대신 k-skill-proxy에 MOLIT 실거래가 API route를 추가해서 다른 스킬(한강수위, 미세먼지 등)과 동일한 패턴으로 사용 가능하게 함. - GET /v1/real-estate/region-code — 지역코드 검색 - GET /v1/real-estate/:assetType/:dealType — 9개 거래 유형 조회 - molit.js: XML 파싱, 필드 정규화, 취소거래 필터링, 요약통계 - region-lookup.js: 284개 법정동 5자리 코드 토큰 매칭 - SKILL.md/docs를 HTTP proxy 기반으로 전면 재작성 - DATA_GO_KR_API_KEY를 사용자 필수항목에서 프록시 운영자 전용으로 이동 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cdae5a93ac
commit
e81388f0d5
18 changed files with 1784 additions and 376 deletions
|
|
@ -2,153 +2,62 @@
|
|||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
- 아파트 매매 실거래가 조회 (`get_apartment_trades`)
|
||||
- 아파트 전월세 조회 (`get_apartment_rent`)
|
||||
- 아파트 매매 실거래가 조회 (`/v1/real-estate/apartment/trade`)
|
||||
- 아파트 전월세 조회 (`/v1/real-estate/apartment/rent`)
|
||||
- 오피스텔/연립다세대/단독주택/상업업무용 실거래가 조회
|
||||
- 지역코드 조회 (`get_region_code`) 후 행정구역 기준 검색
|
||||
- 청약홈 도구 연결
|
||||
- 온비드 코드/주소 조회
|
||||
- 온비드 입찰결과 도구 (`get_public_auction_items`, `get_public_auction_item_detail`)는 upstream README 기준 `⚠️ WIP` 상태로 preview 안내
|
||||
- hosted endpoint가 없을 때 self-host + Cloudflare Tunnel + launchd 운영
|
||||
- 지역코드 조회 (`/v1/real-estate/region-code`) 후 행정구역 기준 검색
|
||||
|
||||
## 가장 중요한 규칙
|
||||
|
||||
이 기능은 upstream **`real-estate-mcp`**(`https://github.com/tae0y/real-estate-mcp/tree/main`)를 그대로 사용한다.
|
||||
이 저장소에는 원본 MCP 서버 코드를 넣지 않고, 스킬 문서와 연결 가이드만 유지한다.
|
||||
|
||||
2026-04-05 기준 upstream README/docs에는 고정 public MCP URL이 문서화돼 있지 않았다.
|
||||
그래서 기본 문서는 **로컬 stdio 연결 또는 self-host HTTP 운영**을 기준으로 적는다.
|
||||
기본 경로는 `https://k-skill-proxy.nomadamas.org/v1/real-estate/...` 이다.
|
||||
사용자는 별도 API key를 준비할 필요가 없다. upstream key는 proxy 서버에서만 주입한다.
|
||||
|
||||
## 먼저 필요한 것
|
||||
|
||||
- 인터넷 연결
|
||||
- `uv`
|
||||
- 공공데이터포털 API key (`DATA_GO_KR_API_KEY`)
|
||||
- upstream clone: `git clone https://github.com/tae0y/real-estate-mcp.git`
|
||||
- shared HTTP가 필요하면 Docker + Cloudflare Tunnel
|
||||
없음. 인터넷 연결만 있으면 된다.
|
||||
|
||||
`DATA_GO_KR_API_KEY` 하나만 넣어도 기본 실거래가 조회는 시작할 수 있다.
|
||||
청약홈/온비드를 더 세밀하게 나누고 싶으면 upstream 문서대로 `ODCLOUD_API_KEY`, `ODCLOUD_SERVICE_KEY`, `ONBID_API_KEY` 를 추가한다.
|
||||
다만 `get_public_auction_items`, `get_public_auction_item_detail` 는 2026-04-05 기준 upstream README 에서 아직 `⚠️ WIP` 로 남아 있으므로, 안정 기능처럼 소개하지 말고 preview/실험 단계로만 설명한다.
|
||||
## 지역코드 검색
|
||||
|
||||
## 가장 빠른 시작: Codex CLI stdio
|
||||
주소/행정구역이 애매하면 먼저 지역코드를 검색한다.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/tae0y/real-estate-mcp.git
|
||||
cd real-estate-mcp
|
||||
|
||||
codex mcp add real-estate \
|
||||
--env DATA_GO_KR_API_KEY=your_api_key_here \
|
||||
-- uv run --directory /path/to/real-estate-mcp \
|
||||
python src/real_estate/mcp_server/server.py
|
||||
|
||||
codex mcp list
|
||||
codex mcp get real-estate
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/real-estate/region-code' \
|
||||
--data-urlencode 'q=마포구'
|
||||
```
|
||||
|
||||
## Claude Desktop stdio 예시
|
||||
응답에서 `lawd_cd` 를 확인한 후 실거래가 조회에 사용한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"real-estate": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"run",
|
||||
"--directory", "/path/to/real-estate-mcp",
|
||||
"python", "src/real_estate/mcp_server/server.py"
|
||||
],
|
||||
"env": {
|
||||
"DATA_GO_KR_API_KEY": "your_api_key_here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## shared HTTP / self-host 운영
|
||||
|
||||
고정 hosted endpoint를 확인하지 못했다면 아래 흐름으로 self-host 한다.
|
||||
|
||||
### 1. upstream Docker compose 시작
|
||||
## 실거래가/전월세 조회
|
||||
|
||||
```bash
|
||||
git clone https://github.com/tae0y/real-estate-mcp.git
|
||||
cd real-estate-mcp
|
||||
cp .env.example .env
|
||||
printf 'DATA_GO_KR_API_KEY=your_api_key_here\n' >> .env
|
||||
REPOSITORY_ROOT=$(pwd)
|
||||
docker compose -f "$REPOSITORY_ROOT"/docker/docker-compose.yml up -d --build
|
||||
# 아파트 매매
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/real-estate/apartment/trade' \
|
||||
--data-urlencode 'lawd_cd=11440' \
|
||||
--data-urlencode 'deal_ymd=202403'
|
||||
|
||||
# 아파트 전월세
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/real-estate/apartment/rent' \
|
||||
--data-urlencode 'lawd_cd=11440' \
|
||||
--data-urlencode 'deal_ymd=202403'
|
||||
|
||||
# 오피스텔 매매
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/real-estate/officetel/trade' \
|
||||
--data-urlencode 'lawd_cd=11680' \
|
||||
--data-urlencode 'deal_ymd=202403'
|
||||
```
|
||||
|
||||
### 2. MCP initialize로 health check
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}}}'
|
||||
```
|
||||
|
||||
정상이라면 응답 JSON 안에 `protocolVersion` 이 보인다.
|
||||
|
||||
### 3. Cloudflare Tunnel로 공유 도메인 만들기
|
||||
|
||||
```bash
|
||||
cloudflared tunnel login
|
||||
cloudflared tunnel create real-estate-mcp
|
||||
cloudflared tunnel route dns real-estate-mcp real-estate-mcp.example.com
|
||||
cat > ~/.cloudflared/config.yml <<'EOF'
|
||||
tunnel: real-estate-mcp
|
||||
credentials-file: /Users/YOUR_USER/.cloudflared/<tunnel-id>.json
|
||||
ingress:
|
||||
- hostname: real-estate-mcp.example.com
|
||||
service: http://localhost:80
|
||||
- service: http_status:404
|
||||
EOF
|
||||
cloudflared tunnel run real-estate-mcp
|
||||
```
|
||||
|
||||
그 다음 MCP URL은 `https://real-estate-mcp.example.com/mcp` 로 잡는다.
|
||||
인터넷에 공개한다면 upstream OAuth 문서(`docs/setup-oauth.md`)대로 `AUTH_MODE=oauth` 와 Auth0/클라이언트 시크릿을 붙인다.
|
||||
|
||||
### 4. launchd로 자동 실행
|
||||
|
||||
macOS 기준으로는 **launchd 를 tunnel 전용으로만** 쓰고, upstream 서버 컨테이너 재시작은 Docker 쪽에 맡긴다.
|
||||
upstream `docker/docker-compose.yml` 이 이미 `restart: unless-stopped` 를 설정하므로, `docker compose -f docker/docker-compose.yml up -d` 를 `RunAtLoad` + `KeepAlive` launchd job 에 넣으면 daemonize 직후 종료된 프로세스를 launchd 가 계속 다시 띄우는 restart loop가 생긴다.
|
||||
|
||||
따라서 서버 쪽은 Docker Desktop/Engine 자동 시작을 켜고 `docker compose ... up -d --build` 를 한 번 실행해 둔 뒤, `cloudflared tunnel run real-estate-mcp` 만 launchd 에 등록한다.
|
||||
|
||||
- `~/Library/LaunchAgents/com.kskill.real-estate-mcp.tunnel.plist`
|
||||
|
||||
```bash
|
||||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.kskill.real-estate-mcp.tunnel.plist
|
||||
launchctl enable gui/$(id -u)/com.kskill.real-estate-mcp.tunnel
|
||||
```
|
||||
지원하는 자산 타입: `apartment`, `officetel`, `villa`, `single-house`, `commercial`
|
||||
지원하는 거래 타입: `trade`, `rent` (commercial은 trade만)
|
||||
|
||||
## 조회 흐름 권장 순서
|
||||
|
||||
1. 주소/행정구역이 애매하면 `get_region_code` 부터 호출한다.
|
||||
2. 아파트 매매면 `get_apartment_trades`, 전월세면 `get_apartment_rent` 를 쓴다.
|
||||
3. 오피스텔/빌라/단독주택/상업업무용은 해당 전용 tool로 분기한다.
|
||||
1. 주소/행정구역이 애매하면 `/v1/real-estate/region-code?q=...` 부터 호출한다.
|
||||
2. 아파트 매매면 `apartment/trade`, 전월세면 `apartment/rent` 를 쓴다.
|
||||
3. 오피스텔/빌라/단독주택/상업업무용은 해당 전용 endpoint로 분기한다.
|
||||
4. 사용자가 연월을 안 줬으면 기준 월을 먼저 확인한다.
|
||||
5. 실거래가와 호가를 섞지 말고, 신고 기반 데이터라는 점을 짧게 명시한다.
|
||||
6. public endpoint가 미리 없다면 self-host + Cloudflare Tunnel + launchd 경로를 그대로 제시한다.
|
||||
|
||||
## 라이브 확인 메모
|
||||
|
||||
2026-04-05 기준 로컬 smoke verification 에서 upstream 저장소로 아래 bootstrap 명령을 실제 실행해 진입 가능 여부를 확인했다.
|
||||
|
||||
- `uv sync`
|
||||
- `uv run real-estate-mcp --help`
|
||||
- `DATA_GO_KR_API_KEY=dummy uv run real-estate-mcp --transport http --host 127.0.0.1 --port 8017`
|
||||
- `curl -s -X POST http://127.0.0.1:8017/mcp ... initialize` → `protocolVersion: 2024-11-05`
|
||||
|
||||
즉, upstream 프로젝트 자체는 로컬에서 실행 가능한 상태로 확인했다. 실제 거래 데이터 조회는 유효한 `DATA_GO_KR_API_KEY` 가 준비된 환경에서 바로 이어서 검증하면 된다.
|
||||
|
||||
## 참고 링크
|
||||
|
||||
- 원본 MCP 서버: `https://github.com/tae0y/real-estate-mcp/tree/main`
|
||||
- Codex CLI 가이드: `https://github.com/tae0y/real-estate-mcp/blob/main/docs/setup-codex-cli.md`
|
||||
- Docker 가이드: `https://github.com/tae0y/real-estate-mcp/blob/main/docs/setup-docker.md`
|
||||
- OAuth 가이드: `https://github.com/tae0y/real-estate-mcp/blob/main/docs/setup-oauth.md`
|
||||
- 원본 MCP 서버 (참고용): `https://github.com/tae0y/real-estate-mcp/tree/main`
|
||||
- 공식 데이터 출처: 공공데이터포털 (`https://www.data.go.kr`)
|
||||
|
|
|
|||
|
|
@ -97,23 +97,7 @@ korean-law list
|
|||
|
||||
로컬 설치가 막히면 `https://korean-law-mcp.fly.dev/mcp` remote endpoint를 MCP 클라이언트에 등록한다. 그 경로도 응답하지 않거나 서비스 장애가 나면 `https://api.beopmang.org/mcp` 또는 `https://api.beopmang.org/api/v4/law?action=search` 를 fallback으로 사용한다.
|
||||
|
||||
`real-estate-search` 는 skill 설치 후 upstream `real-estate-mcp` (`https://github.com/tae0y/real-estate-mcp/tree/main`) 를 따로 clone 해서 붙인다.
|
||||
|
||||
- 로컬 stdio/HTTP/self-host 경로는 `DATA_GO_KR_API_KEY` 를 채운다.
|
||||
- 2026-04-05 기준 upstream 문서에는 고정 public MCP URL이 없어서, shared HTTP가 필요하면 self-host를 기본으로 본다.
|
||||
- Codex CLI 에 붙일 때는 `uv run` 기반 stdio 등록을 먼저 시도한다.
|
||||
- self-host는 upstream Docker 문서 + `cloudflared tunnel`(Cloudflare Tunnel) 조합을 권장하고, macOS `launchd` 는 long-running Cloudflare Tunnel 전용으로만 둔다.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/tae0y/real-estate-mcp.git
|
||||
cd real-estate-mcp
|
||||
codex mcp add real-estate \
|
||||
--env DATA_GO_KR_API_KEY=your-api-key \
|
||||
-- uv run --directory /path/to/real-estate-mcp \
|
||||
python src/real_estate/mcp_server/server.py
|
||||
```
|
||||
|
||||
shared HTTP가 필요하면 upstream Docker guide 대로 서버를 한 번 띄워 Docker의 `restart: unless-stopped` 재시작 정책에 맡긴 뒤 Cloudflare Tunnel 도메인(`https://real-estate-mcp.example.com/mcp`)을 붙인다. macOS에서는 `launchd` 에 서버/터널을 함께 넣지 말고 long-running 프로세스인 `cloudflared tunnel run real-estate-mcp` 만 자동 실행한다. 자세한 흐름은 [한국 부동산 실거래가 조회 가이드](features/real-estate-search.md)를 본다.
|
||||
`real-estate-search` 는 별도 설치 없이 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 통해 바로 사용할 수 있다. 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다. 원본 참고: `https://github.com/tae0y/real-estate-mcp/tree/main`. 자세한 사용법은 [한국 부동산 실거래가 조회 가이드](features/real-estate-search.md)를 본다.
|
||||
|
||||
### `olive-young-search` upstream CLI quickstart
|
||||
|
||||
|
|
|
|||
|
|
@ -25,13 +25,11 @@ KSKILL_SRT_PASSWORD=replace-me
|
|||
KSKILL_KTX_ID=replace-me
|
||||
KSKILL_KTX_PASSWORD=replace-me
|
||||
LAW_OC=replace-me
|
||||
DATA_GO_KR_API_KEY=replace-me
|
||||
OPINET_API_KEY=replace-me
|
||||
AIR_KOREA_OPEN_API_KEY=replace-me
|
||||
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
|
||||
```
|
||||
|
||||
서울 지하철 도착정보는 hosted public route rollout 이 끝나기 전까지 self-host 또는 배포 확인이 끝난 proxy URL 만 넣는다. 미세먼지만 hosted proxy 로 쓸 때는 이 값을 비워 두고 skill 기본값을 써도 된다.
|
||||
서울 지하철 도착정보는 hosted public route rollout 이 끝나기 전까지 self-host 또는 배포 확인이 끝난 proxy URL 만 넣는다. 미세먼지, 한강 수위, 주유소 가격은 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다.
|
||||
|
||||
## Missing secret handling policy
|
||||
|
||||
|
|
@ -63,11 +61,9 @@ KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
|
|||
- `KSKILL_KTX_ID`
|
||||
- `KSKILL_KTX_PASSWORD`
|
||||
- `LAW_OC`
|
||||
- `DATA_GO_KR_API_KEY`
|
||||
- `OPINET_API_KEY`
|
||||
- `AIR_KOREA_OPEN_API_KEY`
|
||||
- `KSKILL_PROXY_BASE_URL`
|
||||
|
||||
`LAW_OC` 는 `korean-law-mcp` 가 법제처 Open API 를 호출할 때 쓰는 표준 변수명이다. 이 값은 로컬 CLI/로컬 MCP server 경로에서만 사용자 쪽에 필요하고, upstream remote MCP endpoint 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다. `DATA_GO_KR_API_KEY` 는 upstream `real-estate-mcp` 의 로컬/stdio/self-host 경로에서 쓰는 표준 변수명이다. `OPINET_API_KEY` 는 한국석유공사 Opinet Open API 인증키로, 근처 가장 싼 주유소 찾기 skill/package 의 공식 nearby 가격 조회에 사용한다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY`, `AIR_KOREA_OPEN_API_KEY`, `HRFCO_OPEN_API_KEY` 를 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 은 서울 지하철 route가 실제 배포된 proxy URL 로만 넣는다. 미세먼지와 한강 수위는 이 값이 없으면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 사용한다.
|
||||
`LAW_OC` 는 `korean-law-mcp` 가 법제처 Open API 를 호출할 때 쓰는 표준 변수명이다. 이 값은 로컬 CLI/로컬 MCP server 경로에서만 사용자 쪽에 필요하고, upstream remote MCP endpoint 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다. `DATA_GO_KR_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 키가 불필요하다. 근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다. `OPINET_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY`, `AIR_KOREA_OPEN_API_KEY`, `HRFCO_OPEN_API_KEY`, `OPINET_API_KEY`, `DATA_GO_KR_API_KEY` 를 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 은 서울 지하철 route가 실제 배포된 proxy URL 로만 넣는다. 미세먼지, 한강 수위, 주유소 가격은 이 값이 없으면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 사용한다.
|
||||
|
||||
이 레포의 credential-bearing skill은 전부 이 정책을 전제로 작성한다. 자세한 공통 설치 절차는 [공통 설정 가이드](setup.md)를 본다.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# 공통 설정 가이드
|
||||
|
||||
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, 한국 부동산 실거래가 조회의 로컬/self-host 경로용 `DATA_GO_KR_API_KEY`, 근처 가장 싼 주유소 찾기용 `OPINET_API_KEY`, self-host 프록시 운영용 서울 지하철/미세먼지/한강홍수통제소 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다.
|
||||
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, self-host 프록시 운영용 서울 지하철 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다. 미세먼지, 한강 수위, 주유소 가격, 부동산 실거래가는 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다.
|
||||
|
||||
## Credential resolution order
|
||||
|
||||
|
|
@ -25,8 +25,6 @@ KSKILL_SRT_PASSWORD=replace-me
|
|||
KSKILL_KTX_ID=replace-me
|
||||
KSKILL_KTX_PASSWORD=replace-me
|
||||
LAW_OC=replace-me
|
||||
DATA_GO_KR_API_KEY=replace-me
|
||||
OPINET_API_KEY=replace-me
|
||||
AIR_KOREA_OPEN_API_KEY=replace-me
|
||||
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
|
||||
EOF
|
||||
|
|
@ -35,15 +33,15 @@ chmod 0600 ~/.config/k-skill/secrets.env
|
|||
|
||||
실제 값을 채운다.
|
||||
|
||||
서울 지하철 도착정보는 hosted public route rollout 이 끝나기 전까지 `KSKILL_PROXY_BASE_URL` 을 self-host 또는 배포 확인이 끝난 proxy URL 로 채워야 한다. 미세먼지와 한강 수위는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다.
|
||||
서울 지하철 도착정보는 hosted public route rollout 이 끝나기 전까지 `KSKILL_PROXY_BASE_URL` 을 self-host 또는 배포 확인이 끝난 proxy URL 로 채워야 한다. 미세먼지, 한강 수위, 주유소 가격은 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다.
|
||||
|
||||
한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC` 는 `korean-law-mcp` 로컬 실행에 쓴다. 로컬 CLI/MCP 경로는 `LAW_OC` 를 채운 뒤 `npm install -g korean-law-mcp` 와 `korean-law list` 로 설치 상태를 확인한다.
|
||||
|
||||
remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결한다. 다만 기존 `korean-law-mcp` 경로가 서비스 장애로 막히면 `법망`(`https://api.beopmang.org`) MCP/REST를 fallback으로 사용할 수 있다.
|
||||
|
||||
한국 부동산 실거래가 조회의 로컬/self-host 경로는 upstream `real-estate-mcp` 가 읽는 `DATA_GO_KR_API_KEY` 를 채운다. 2026-04-05 기준 upstream 문서에는 고정 public endpoint가 없어 self-host를 기본으로 보고, Cloudflare Tunnel/operator secret은 운영자별 값이라 기본 client secrets 파일에는 넣지 않는다.
|
||||
한국 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다.
|
||||
|
||||
근처 가장 싼 주유소 찾기는 한국석유공사 Opinet Open API 인증키인 `OPINET_API_KEY` 를 채운다. 이 값이 없으면 공식 nearby 가격 조회를 시작할 수 없으므로 비공식 가격 서비스로 자동 우회하지 않는다.
|
||||
근처 가장 싼 주유소 찾기는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다.
|
||||
|
||||
## 확인
|
||||
|
||||
|
|
@ -66,9 +64,8 @@ bash scripts/check-setup.sh
|
|||
| KTX 예매 | `KSKILL_KTX_ID`, `KSKILL_KTX_PASSWORD` |
|
||||
| 한국 법령 검색 (로컬 CLI/MCP) | `LAW_OC` |
|
||||
| 한국 법령 검색 (remote MCP endpoint) | 사용자 시크릿 불필요 (`url`만 등록, 장애 시 `법망` fallback 가능) |
|
||||
| 한국 부동산 실거래가 조회 (로컬/stdio/self-host) | `DATA_GO_KR_API_KEY` |
|
||||
| 근처 가장 싼 주유소 찾기 | `OPINET_API_KEY` |
|
||||
| 한국 부동산 실거래가 조회 (공유 URL) | 사용자 시크릿 불필요, 대신 운영자가 self-host + Cloudflare Tunnel + launchd/systemd 를 준비 |
|
||||
| 한국 부동산 실거래가 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
|
||||
| 근처 가장 싼 주유소 찾기 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
|
||||
| 서울 지하철 도착정보 조회 | self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL` |
|
||||
| 사용자 위치 미세먼지 조회 | `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY` |
|
||||
| 한강 수위 정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
|
||||
|
|
|
|||
|
|
@ -22,9 +22,15 @@
|
|||
- `hwp-mcp`: https://github.com/jkf87/hwp-mcp
|
||||
- korean-law-mcp: https://github.com/chrisryugj/korean-law-mcp
|
||||
- real-estate-mcp: https://github.com/tae0y/real-estate-mcp/tree/main
|
||||
- real-estate-mcp Codex guide: https://github.com/tae0y/real-estate-mcp/blob/main/docs/setup-codex-cli.md
|
||||
- real-estate-mcp Docker guide: https://github.com/tae0y/real-estate-mcp/blob/main/docs/setup-docker.md
|
||||
- real-estate-mcp OAuth guide: https://github.com/tae0y/real-estate-mcp/blob/main/docs/setup-oauth.md
|
||||
- MOLIT 아파트 매매 실거래가 API: https://apis.data.go.kr/1613000/RTMSDataSvcAptTrade/getRTMSDataSvcAptTrade
|
||||
- MOLIT 아파트 전월세 API: https://apis.data.go.kr/1613000/RTMSDataSvcAptRent/getRTMSDataSvcAptRent
|
||||
- MOLIT 오피스텔 매매 API: https://apis.data.go.kr/1613000/RTMSDataSvcOffiTrade/getRTMSDataSvcOffiTrade
|
||||
- MOLIT 오피스텔 전월세 API: https://apis.data.go.kr/1613000/RTMSDataSvcOffiRent/getRTMSDataSvcOffiRent
|
||||
- MOLIT 연립다세대 매매 API: https://apis.data.go.kr/1613000/RTMSDataSvcRHTrade/getRTMSDataSvcRHTrade
|
||||
- MOLIT 연립다세대 전월세 API: https://apis.data.go.kr/1613000/RTMSDataSvcRHRent/getRTMSDataSvcRHRent
|
||||
- MOLIT 단독/다가구 매매 API: https://apis.data.go.kr/1613000/RTMSDataSvcSHTrade/getRTMSDataSvcSHTrade
|
||||
- MOLIT 단독/다가구 전월세 API: https://apis.data.go.kr/1613000/RTMSDataSvcSHRent/getRTMSDataSvcSHRent
|
||||
- MOLIT 상업업무용 매매 API: https://apis.data.go.kr/1613000/RTMSDataSvcNrgTrade/getRTMSDataSvcNrgTrade
|
||||
- beopmang: https://api.beopmang.org
|
||||
- `silver-flight-group/kakaocli`: https://github.com/silver-flight-group/kakaocli
|
||||
- KakaoTalk Mac 설치 참고(`mas`): https://velog.io/@bonjugi/%EB%A7%A5%EB%B6%81-M1%EC%97%90-homebrew%EB%A1%9C-node-vscode-%EC%B9%B4%EC%B9%B4%EC%98%A4%ED%86%A1-%EC%84%A4%EC%B9%98%ED%95%98%EA%B8%B0
|
||||
|
|
|
|||
|
|
@ -3,7 +3,5 @@ KSKILL_SRT_PASSWORD=replace-me
|
|||
KSKILL_KTX_ID=replace-me
|
||||
KSKILL_KTX_PASSWORD=replace-me
|
||||
LAW_OC=replace-me
|
||||
DATA_GO_KR_API_KEY=replace-me
|
||||
OPINET_API_KEY=replace-me
|
||||
AIR_KOREA_OPEN_API_KEY=replace-me
|
||||
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
|
||||
|
|
|
|||
|
|
@ -68,8 +68,6 @@ KSKILL_SRT_PASSWORD=replace-me
|
|||
KSKILL_KTX_ID=replace-me
|
||||
KSKILL_KTX_PASSWORD=replace-me
|
||||
LAW_OC=replace-me
|
||||
DATA_GO_KR_API_KEY=replace-me
|
||||
OPINET_API_KEY=replace-me
|
||||
AIR_KOREA_OPEN_API_KEY=replace-me
|
||||
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
|
||||
EOF
|
||||
|
|
@ -78,13 +76,13 @@ chmod 0600 ~/.config/k-skill/secrets.env
|
|||
|
||||
유저에게 물어서 실제 값을 채운다.
|
||||
|
||||
서울 지하철 도착정보는 hosted public route rollout 이 끝나기 전까지 `KSKILL_PROXY_BASE_URL` 을 self-host 또는 배포 확인이 끝난 proxy URL 로 채운다. 미세먼지와 한강 수위는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다.
|
||||
서울 지하철 도착정보는 hosted public route rollout 이 끝나기 전까지 `KSKILL_PROXY_BASE_URL` 을 self-host 또는 배포 확인이 끝난 proxy URL 로 채운다. 미세먼지, 한강 수위, 주유소 가격은 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다.
|
||||
|
||||
한국 법령 검색은 로컬 `korean-law-mcp` 경로를 쓸 때만 `LAW_OC` 를 채운다. remote endpoint는 사용자 `LAW_OC` 없이 `url`만 등록하면 되고, 기존 경로 장애 시에는 `법망`(`https://api.beopmang.org`)을 fallback으로 안내한다.
|
||||
|
||||
한국 부동산 실거래가 조회는 upstream `real-estate-mcp` 의 로컬/stdio/self-host 경로를 쓸 때 `DATA_GO_KR_API_KEY` 를 채운다. 2026-04-05 기준 고정 public endpoint는 확인하지 못했으므로 shared URL이 필요하면 self-host + Cloudflare Tunnel + launchd(systemd) 운영을 먼저 설명한다.
|
||||
한국 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다.
|
||||
|
||||
근처 가장 싼 주유소 찾기는 한국석유공사 Opinet Open API 인증키인 `OPINET_API_KEY` 를 채운다. 이 값이 없으면 공식 nearby 가격 조회를 시작할 수 없으므로 비공식 가격 서비스로 자동 우회하지 않는다.
|
||||
근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다.
|
||||
|
||||
|
||||
### Missing secret response template
|
||||
|
|
@ -97,9 +95,8 @@ chmod 0600 ~/.config/k-skill/secrets.env
|
|||
- KTX: `KSKILL_KTX_ID`, `KSKILL_KTX_PASSWORD`
|
||||
- 로컬 한국 법령 검색: `LAW_OC` + `korean-law-mcp`
|
||||
- 한국 법령 검색 remote endpoint: 사용자 `LAW_OC` 없이 `url`만 등록, 장애 시 `법망` fallback
|
||||
- 로컬/stdio 한국 부동산 실거래가 조회: `DATA_GO_KR_API_KEY` + `real-estate-mcp`
|
||||
- 근처 가장 싼 주유소 찾기: `OPINET_API_KEY` + `cheap-gas-nearby`
|
||||
- 공유형 한국 부동산 실거래가 조회: 운영자가 self-host + Cloudflare Tunnel + launchd/systemd 준비
|
||||
- 한국 부동산 실거래가 조회: 사용자 시크릿 불필요 (기본 hosted proxy 사용)
|
||||
- 근처 가장 싼 주유소 찾기: 사용자 시크릿 불필요 (기본 hosted proxy 사용)
|
||||
- 서울 지하철: self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL`
|
||||
- 사용자 위치 미세먼지 조회: `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY`
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"node": ">=18"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "node --check src/airkorea.js && node --check src/hrfco.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/server.test.js",
|
||||
"lint": "node --check src/airkorea.js && node --check src/hrfco.js && node --check src/molit.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/molit.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
258
packages/k-skill-proxy/src/molit.js
Normal file
258
packages/k-skill-proxy/src/molit.js
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
// MOLIT (Ministry of Land, Infrastructure and Transport) real estate API wrapper.
|
||||
// Proxies data.go.kr XML endpoints for Korean real estate transaction data.
|
||||
|
||||
const MOLIT_BASE_URL = "http://apis.data.go.kr/1613000";
|
||||
|
||||
const ENDPOINT_MAP = new Map([
|
||||
["apartment/trade", "RTMSDataSvcAptTrade/getRTMSDataSvcAptTrade"],
|
||||
["apartment/rent", "RTMSDataSvcAptRent/getRTMSDataSvcAptRent"],
|
||||
["officetel/trade", "RTMSDataSvcOffiTrade/getRTMSDataSvcOffiTrade"],
|
||||
["officetel/rent", "RTMSDataSvcOffiRent/getRTMSDataSvcOffiRent"],
|
||||
["villa/trade", "RTMSDataSvcRHTrade/getRTMSDataSvcRHTrade"],
|
||||
["villa/rent", "RTMSDataSvcRHRent/getRTMSDataSvcRHRent"],
|
||||
["single-house/trade", "RTMSDataSvcSHTrade/getRTMSDataSvcSHTrade"],
|
||||
["single-house/rent", "RTMSDataSvcSHRent/getRTMSDataSvcSHRent"],
|
||||
["commercial/trade", "RTMSDataSvcNrgTrade/getRTMSDataSvcNrgTrade"],
|
||||
]);
|
||||
|
||||
const VALID_ASSET_TYPES = new Set(["apartment", "officetel", "villa", "single-house", "commercial"]);
|
||||
const VALID_DEAL_TYPES = new Set(["trade", "rent"]);
|
||||
|
||||
// XML tag → JSON key mapping per asset type for trade responses.
|
||||
// name_tag: XML tag for the property name
|
||||
// area_tag: XML tag for the area field
|
||||
// cancel_tag: XML tag for cancellation marker
|
||||
// extra_fields: additional fields specific to the asset type
|
||||
const TRADE_SCHEMA = {
|
||||
apartment: { name_tag: "aptNm", area_tag: "excluUseAr", cancel_tag: "cdealType", extra_fields: [] },
|
||||
officetel: { name_tag: "offiNm", area_tag: "excluUseAr", cancel_tag: "cdealType", extra_fields: [] },
|
||||
villa: { name_tag: "mhouseNm", area_tag: "excluUseAr", cancel_tag: "cdealType", extra_fields: ["houseType"] },
|
||||
"single-house": { name_tag: null, area_tag: "totalFloorAr", cancel_tag: "cdealType", extra_fields: ["houseType"], floor_fixed: 0 },
|
||||
commercial: { name_tag: null, area_tag: "buildingAr", cancel_tag: "cdealtype", extra_fields: ["buildingType", "buildingUse", "landUse", "shareDealingType"] },
|
||||
};
|
||||
|
||||
const RENT_SCHEMA = {
|
||||
apartment: { name_tag: "aptNm", area_tag: "excluUseAr", extra_fields: [] },
|
||||
officetel: { name_tag: "offiNm", area_tag: "excluUseAr", extra_fields: [] },
|
||||
villa: { name_tag: "mhouseNm", area_tag: "excluUseAr", extra_fields: ["houseType"] },
|
||||
"single-house": { name_tag: null, area_tag: "totalFloorAr", extra_fields: ["houseType"] },
|
||||
};
|
||||
|
||||
function extractTag(itemXml, tagName) {
|
||||
const re = new RegExp(`<${tagName}>\\s*([^<]*)\\s*</${tagName}>`);
|
||||
const m = itemXml.match(re);
|
||||
return m ? m[1].trim() : "";
|
||||
}
|
||||
|
||||
function parseAmount(raw) {
|
||||
const cleaned = raw.replace(/,/g, "");
|
||||
const n = parseInt(cleaned, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function parseFloatValue(raw) {
|
||||
const n = parseFloat(raw);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function parseIntValue(raw) {
|
||||
const n = parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function makeDate(itemXml) {
|
||||
const year = extractTag(itemXml, "dealYear");
|
||||
const month = extractTag(itemXml, "dealMonth").padStart(2, "0");
|
||||
const day = extractTag(itemXml, "dealDay").padStart(2, "0");
|
||||
return year ? `${year}-${month}-${day}` : "";
|
||||
}
|
||||
|
||||
// Regex-based XML parser for MOLIT's flat <item> structure.
|
||||
// Not a general-purpose XML parser — sufficient for data.go.kr MOLIT responses.
|
||||
function parseXmlItems(xmlText) {
|
||||
const codeMatch = xmlText.match(/<resultCode>(\d+)<\/resultCode>/);
|
||||
if (!codeMatch) {
|
||||
return { error: "parse_error", message: "No resultCode in response" };
|
||||
}
|
||||
const resultCode = codeMatch[1];
|
||||
if (resultCode !== "000") {
|
||||
const msgMatch = xmlText.match(/<resultMsg>([^<]*)<\/resultMsg>/);
|
||||
const resultMsg = msgMatch ? msgMatch[1].trim() : `API error code ${resultCode}`;
|
||||
return { error: `molit_api_${resultCode}`, message: resultMsg };
|
||||
}
|
||||
|
||||
const totalMatch = xmlText.match(/<totalCount>(\d+)<\/totalCount>/);
|
||||
const totalCount = totalMatch ? parseInt(totalMatch[1], 10) : 0;
|
||||
|
||||
const items = [];
|
||||
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
|
||||
let match;
|
||||
while ((match = itemRegex.exec(xmlText)) !== null) {
|
||||
items.push(match[1]);
|
||||
}
|
||||
|
||||
return { totalCount, items };
|
||||
}
|
||||
|
||||
function normalizeTradeItem(itemXml, assetType) {
|
||||
const schema = TRADE_SCHEMA[assetType];
|
||||
if (!schema) return null;
|
||||
|
||||
const cancelVal = extractTag(itemXml, schema.cancel_tag);
|
||||
if (cancelVal === "O") return null;
|
||||
|
||||
const price = parseAmount(extractTag(itemXml, "dealAmount"));
|
||||
if (price === null) return null;
|
||||
|
||||
const result = {
|
||||
name: schema.name_tag ? extractTag(itemXml, schema.name_tag) : "",
|
||||
district: extractTag(itemXml, "umdNm"),
|
||||
area_m2: parseFloatValue(extractTag(itemXml, schema.area_tag)),
|
||||
floor: schema.floor_fixed !== undefined ? schema.floor_fixed : parseIntValue(extractTag(itemXml, "floor")),
|
||||
price_10k: price,
|
||||
deal_date: makeDate(itemXml),
|
||||
build_year: parseIntValue(extractTag(itemXml, "buildYear")),
|
||||
deal_type: extractTag(itemXml, "dealingGbn"),
|
||||
};
|
||||
|
||||
for (const field of schema.extra_fields) {
|
||||
result[field] = extractTag(itemXml, field);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeRentItem(itemXml, assetType) {
|
||||
const schema = RENT_SCHEMA[assetType];
|
||||
if (!schema) return null;
|
||||
|
||||
const cancelVal = extractTag(itemXml, "cdealType");
|
||||
if (cancelVal === "O") return null;
|
||||
|
||||
const deposit = parseAmount(extractTag(itemXml, "deposit"));
|
||||
if (deposit === null) return null;
|
||||
|
||||
const monthlyRentRaw = extractTag(itemXml, "monthlyRent");
|
||||
const monthlyRent = monthlyRentRaw ? (parseAmount(monthlyRentRaw) || 0) : 0;
|
||||
|
||||
const result = {
|
||||
name: schema.name_tag ? extractTag(itemXml, schema.name_tag) : "",
|
||||
district: extractTag(itemXml, "umdNm"),
|
||||
area_m2: parseFloatValue(extractTag(itemXml, schema.area_tag)),
|
||||
floor: parseIntValue(extractTag(itemXml, "floor")),
|
||||
deposit_10k: deposit,
|
||||
monthly_rent_10k: monthlyRent,
|
||||
contract_type: extractTag(itemXml, "contractType"),
|
||||
deal_date: makeDate(itemXml),
|
||||
build_year: parseIntValue(extractTag(itemXml, "buildYear")),
|
||||
};
|
||||
|
||||
for (const field of schema.extra_fields) {
|
||||
result[field] = extractTag(itemXml, field);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function median(arr) {
|
||||
if (arr.length === 0) return 0;
|
||||
const sorted = [...arr].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 !== 0 ? sorted[mid] : Math.floor((sorted[mid - 1] + sorted[mid]) / 2);
|
||||
}
|
||||
|
||||
function mean(arr) {
|
||||
if (arr.length === 0) return 0;
|
||||
return Math.floor(arr.reduce((s, v) => s + v, 0) / arr.length);
|
||||
}
|
||||
|
||||
function computeTradeSummary(items) {
|
||||
if (items.length === 0) {
|
||||
return { median_price_10k: 0, min_price_10k: 0, max_price_10k: 0, sample_count: 0 };
|
||||
}
|
||||
const prices = items.map((it) => it.price_10k);
|
||||
return {
|
||||
median_price_10k: median(prices),
|
||||
min_price_10k: Math.min(...prices),
|
||||
max_price_10k: Math.max(...prices),
|
||||
sample_count: prices.length,
|
||||
};
|
||||
}
|
||||
|
||||
function computeRentSummary(items) {
|
||||
if (items.length === 0) {
|
||||
return { median_deposit_10k: 0, min_deposit_10k: 0, max_deposit_10k: 0, monthly_rent_avg_10k: 0, sample_count: 0 };
|
||||
}
|
||||
const deposits = items.map((it) => it.deposit_10k);
|
||||
const rents = items.map((it) => it.monthly_rent_10k);
|
||||
return {
|
||||
median_deposit_10k: median(deposits),
|
||||
min_deposit_10k: Math.min(...deposits),
|
||||
max_deposit_10k: Math.max(...deposits),
|
||||
monthly_rent_avg_10k: mean(rents),
|
||||
sample_count: deposits.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchTransactions({ assetType, dealType, lawdCd, dealYmd, numOfRows = 100, serviceKey, fetchImpl }) {
|
||||
const endpointKey = `${assetType}/${dealType}`;
|
||||
const path = ENDPOINT_MAP.get(endpointKey);
|
||||
if (!path) {
|
||||
return { error: "invalid_endpoint", message: `Unknown endpoint: ${endpointKey}` };
|
||||
}
|
||||
|
||||
const url = new URL(`${MOLIT_BASE_URL}/${path}`);
|
||||
url.searchParams.set("LAWD_CD", lawdCd);
|
||||
url.searchParams.set("DEAL_YMD", dealYmd);
|
||||
url.searchParams.set("numOfRows", String(numOfRows));
|
||||
url.searchParams.set("pageNo", "1");
|
||||
url.searchParams.set("serviceKey", serviceKey);
|
||||
|
||||
const doFetch = fetchImpl || globalThis.fetch;
|
||||
let response;
|
||||
try {
|
||||
response = await doFetch(url.toString(), { signal: AbortSignal.timeout(20000) });
|
||||
} catch (err) {
|
||||
return { error: "upstream_timeout", message: `Upstream request failed: ${err.message}` };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return { error: "upstream_error", message: `Upstream returned ${response.status}` };
|
||||
}
|
||||
|
||||
const xmlText = await response.text();
|
||||
const parsed = parseXmlItems(xmlText);
|
||||
if (parsed.error) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const normalize = dealType === "trade" ? normalizeTradeItem : normalizeRentItem;
|
||||
const items = [];
|
||||
for (const rawItem of parsed.items) {
|
||||
const normalized = normalize(rawItem, assetType);
|
||||
if (normalized) items.push(normalized);
|
||||
}
|
||||
|
||||
const summary = dealType === "trade" ? computeTradeSummary(items) : computeRentSummary(items);
|
||||
|
||||
return {
|
||||
items,
|
||||
summary,
|
||||
total_count: parsed.totalCount,
|
||||
filtered_count: items.length,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ENDPOINT_MAP,
|
||||
VALID_ASSET_TYPES,
|
||||
VALID_DEAL_TYPES,
|
||||
parseXmlItems,
|
||||
extractTag,
|
||||
normalizeTradeItem,
|
||||
normalizeRentItem,
|
||||
computeTradeSummary,
|
||||
computeRentSummary,
|
||||
fetchTransactions,
|
||||
median,
|
||||
};
|
||||
286
packages/k-skill-proxy/src/region-codes.json
Normal file
286
packages/k-skill-proxy/src/region-codes.json
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
{
|
||||
"11000": "서울특별시",
|
||||
"11110": "서울특별시 종로구",
|
||||
"11140": "서울특별시 중구",
|
||||
"11170": "서울특별시 용산구",
|
||||
"11200": "서울특별시 성동구",
|
||||
"11215": "서울특별시 광진구",
|
||||
"11230": "서울특별시 동대문구",
|
||||
"11260": "서울특별시 중랑구",
|
||||
"11290": "서울특별시 성북구",
|
||||
"11305": "서울특별시 강북구",
|
||||
"11320": "서울특별시 도봉구",
|
||||
"11350": "서울특별시 노원구",
|
||||
"11380": "서울특별시 은평구",
|
||||
"11410": "서울특별시 서대문구",
|
||||
"11440": "서울특별시 마포구",
|
||||
"11470": "서울특별시 양천구",
|
||||
"11500": "서울특별시 강서구",
|
||||
"11530": "서울특별시 구로구",
|
||||
"11545": "서울특별시 금천구",
|
||||
"11560": "서울특별시 영등포구",
|
||||
"11590": "서울특별시 동작구",
|
||||
"11620": "서울특별시 관악구",
|
||||
"11650": "서울특별시 서초구",
|
||||
"11680": "서울특별시 강남구",
|
||||
"11710": "서울특별시 송파구",
|
||||
"11740": "서울특별시 강동구",
|
||||
"26000": "부산광역시",
|
||||
"26110": "부산광역시 중구",
|
||||
"26140": "부산광역시 서구",
|
||||
"26170": "부산광역시 동구",
|
||||
"26200": "부산광역시 영도구",
|
||||
"26230": "부산광역시 부산진구",
|
||||
"26260": "부산광역시 동래구",
|
||||
"26290": "부산광역시 남구",
|
||||
"26320": "부산광역시 북구",
|
||||
"26350": "부산광역시 해운대구",
|
||||
"26380": "부산광역시 사하구",
|
||||
"26410": "부산광역시 금정구",
|
||||
"26440": "부산광역시 강서구",
|
||||
"26470": "부산광역시 연제구",
|
||||
"26500": "부산광역시 수영구",
|
||||
"26530": "부산광역시 사상구",
|
||||
"26710": "부산광역시 기장군",
|
||||
"27000": "대구광역시",
|
||||
"27110": "대구광역시 중구",
|
||||
"27140": "대구광역시 동구",
|
||||
"27170": "대구광역시 서구",
|
||||
"27200": "대구광역시 남구",
|
||||
"27230": "대구광역시 북구",
|
||||
"27260": "대구광역시 수성구",
|
||||
"27290": "대구광역시 달서구",
|
||||
"27710": "대구광역시 달성군",
|
||||
"27720": "대구광역시 군위군",
|
||||
"28000": "인천광역시",
|
||||
"28110": "인천광역시 중구",
|
||||
"28140": "인천광역시 동구",
|
||||
"28177": "인천광역시 미추홀구",
|
||||
"28185": "인천광역시 연수구",
|
||||
"28200": "인천광역시 남동구",
|
||||
"28237": "인천광역시 부평구",
|
||||
"28245": "인천광역시 계양구",
|
||||
"28260": "인천광역시 서구",
|
||||
"28710": "인천광역시 강화군",
|
||||
"28720": "인천광역시 옹진군",
|
||||
"29000": "광주광역시",
|
||||
"29110": "광주광역시 동구",
|
||||
"29140": "광주광역시 서구",
|
||||
"29155": "광주광역시 남구",
|
||||
"29170": "광주광역시 북구",
|
||||
"29200": "광주광역시 광산구",
|
||||
"30000": "대전광역시",
|
||||
"30110": "대전광역시 동구",
|
||||
"30140": "대전광역시 중구",
|
||||
"30170": "대전광역시 서구",
|
||||
"30200": "대전광역시 유성구",
|
||||
"30230": "대전광역시 대덕구",
|
||||
"31000": "울산광역시",
|
||||
"31110": "울산광역시 중구",
|
||||
"31140": "울산광역시 남구",
|
||||
"31170": "울산광역시 동구",
|
||||
"31200": "울산광역시 북구",
|
||||
"31710": "울산광역시 울주군",
|
||||
"36110": "세종특별자치시",
|
||||
"41000": "경기도",
|
||||
"41110": "경기도 수원시",
|
||||
"41111": "경기도 수원시 장안구",
|
||||
"41113": "경기도 수원시 권선구",
|
||||
"41115": "경기도 수원시 팔달구",
|
||||
"41117": "경기도 수원시 영통구",
|
||||
"41130": "경기도 성남시",
|
||||
"41131": "경기도 성남시 수정구",
|
||||
"41133": "경기도 성남시 중원구",
|
||||
"41135": "경기도 성남시 분당구",
|
||||
"41150": "경기도 의정부시",
|
||||
"41170": "경기도 안양시",
|
||||
"41171": "경기도 안양시 만안구",
|
||||
"41173": "경기도 안양시 동안구",
|
||||
"41190": "경기도 부천시",
|
||||
"41192": "경기도 부천시 원미구",
|
||||
"41194": "경기도 부천시 소사구",
|
||||
"41196": "경기도 부천시 오정구",
|
||||
"41210": "경기도 광명시",
|
||||
"41220": "경기도 평택시",
|
||||
"41250": "경기도 동두천시",
|
||||
"41270": "경기도 안산시",
|
||||
"41271": "경기도 안산시 상록구",
|
||||
"41273": "경기도 안산시 단원구",
|
||||
"41280": "경기도 고양시",
|
||||
"41281": "경기도 고양시 덕양구",
|
||||
"41285": "경기도 고양시 일산동구",
|
||||
"41287": "경기도 고양시 일산서구",
|
||||
"41290": "경기도 과천시",
|
||||
"41310": "경기도 구리시",
|
||||
"41360": "경기도 남양주시",
|
||||
"41370": "경기도 오산시",
|
||||
"41390": "경기도 시흥시",
|
||||
"41410": "경기도 군포시",
|
||||
"41430": "경기도 의왕시",
|
||||
"41450": "경기도 하남시",
|
||||
"41460": "경기도 용인시",
|
||||
"41461": "경기도 용인시 처인구",
|
||||
"41463": "경기도 용인시 기흥구",
|
||||
"41465": "경기도 용인시 수지구",
|
||||
"41480": "경기도 파주시",
|
||||
"41500": "경기도 이천시",
|
||||
"41550": "경기도 안성시",
|
||||
"41570": "경기도 김포시",
|
||||
"41590": "경기도 화성시",
|
||||
"41591": "경기도 화성시 만세구",
|
||||
"41593": "경기도 화성시 효행구",
|
||||
"41595": "경기도 화성시 병점구",
|
||||
"41597": "경기도 화성시 동탄구",
|
||||
"41610": "경기도 광주시",
|
||||
"41630": "경기도 양주시",
|
||||
"41650": "경기도 포천시",
|
||||
"41670": "경기도 여주시",
|
||||
"41800": "경기도 연천군",
|
||||
"41820": "경기도 가평군",
|
||||
"41830": "경기도 양평군",
|
||||
"43000": "충청북도",
|
||||
"43110": "충청북도 청주시",
|
||||
"43111": "충청북도 청주시 상당구",
|
||||
"43112": "충청북도 청주시 서원구",
|
||||
"43113": "충청북도 청주시 흥덕구",
|
||||
"43114": "충청북도 청주시 청원구",
|
||||
"43130": "충청북도 충주시",
|
||||
"43150": "충청북도 제천시",
|
||||
"43720": "충청북도 보은군",
|
||||
"43730": "충청북도 옥천군",
|
||||
"43740": "충청북도 영동군",
|
||||
"43745": "충청북도 증평군",
|
||||
"43750": "충청북도 진천군",
|
||||
"43760": "충청북도 괴산군",
|
||||
"43770": "충청북도 음성군",
|
||||
"43800": "충청북도 단양군",
|
||||
"44000": "충청남도",
|
||||
"44130": "충청남도 천안시",
|
||||
"44131": "충청남도 천안시 동남구",
|
||||
"44133": "충청남도 천안시 서북구",
|
||||
"44150": "충청남도 공주시",
|
||||
"44180": "충청남도 보령시",
|
||||
"44200": "충청남도 아산시",
|
||||
"44210": "충청남도 서산시",
|
||||
"44230": "충청남도 논산시",
|
||||
"44250": "충청남도 계룡시",
|
||||
"44270": "충청남도 당진시",
|
||||
"44710": "충청남도 금산군",
|
||||
"44760": "충청남도 부여군",
|
||||
"44770": "충청남도 서천군",
|
||||
"44790": "충청남도 청양군",
|
||||
"44800": "충청남도 홍성군",
|
||||
"44810": "충청남도 예산군",
|
||||
"44825": "충청남도 태안군",
|
||||
"46000": "전라남도",
|
||||
"46110": "전라남도 목포시",
|
||||
"46130": "전라남도 여수시",
|
||||
"46150": "전라남도 순천시",
|
||||
"46170": "전라남도 나주시",
|
||||
"46230": "전라남도 광양시",
|
||||
"46710": "전라남도 담양군",
|
||||
"46720": "전라남도 곡성군",
|
||||
"46730": "전라남도 구례군",
|
||||
"46770": "전라남도 고흥군",
|
||||
"46780": "전라남도 보성군",
|
||||
"46790": "전라남도 화순군",
|
||||
"46800": "전라남도 장흥군",
|
||||
"46810": "전라남도 강진군",
|
||||
"46820": "전라남도 해남군",
|
||||
"46830": "전라남도 영암군",
|
||||
"46840": "전라남도 무안군",
|
||||
"46860": "전라남도 함평군",
|
||||
"46870": "전라남도 영광군",
|
||||
"46880": "전라남도 장성군",
|
||||
"46890": "전라남도 완도군",
|
||||
"46900": "전라남도 진도군",
|
||||
"46910": "전라남도 신안군",
|
||||
"47000": "경상북도",
|
||||
"47110": "경상북도 포항시",
|
||||
"47111": "경상북도 포항시 남구",
|
||||
"47113": "경상북도 포항시 북구",
|
||||
"47130": "경상북도 경주시",
|
||||
"47150": "경상북도 김천시",
|
||||
"47170": "경상북도 안동시",
|
||||
"47190": "경상북도 구미시",
|
||||
"47210": "경상북도 영주시",
|
||||
"47230": "경상북도 영천시",
|
||||
"47250": "경상북도 상주시",
|
||||
"47280": "경상북도 문경시",
|
||||
"47290": "경상북도 경산시",
|
||||
"47730": "경상북도 의성군",
|
||||
"47750": "경상북도 청송군",
|
||||
"47760": "경상북도 영양군",
|
||||
"47770": "경상북도 영덕군",
|
||||
"47820": "경상북도 청도군",
|
||||
"47830": "경상북도 고령군",
|
||||
"47840": "경상북도 성주군",
|
||||
"47850": "경상북도 칠곡군",
|
||||
"47900": "경상북도 예천군",
|
||||
"47920": "경상북도 봉화군",
|
||||
"47930": "경상북도 울진군",
|
||||
"47940": "경상북도 울릉군",
|
||||
"48000": "경상남도",
|
||||
"48120": "경상남도 창원시",
|
||||
"48121": "경상남도 창원시 의창구",
|
||||
"48123": "경상남도 창원시 성산구",
|
||||
"48125": "경상남도 창원시 마산합포구",
|
||||
"48127": "경상남도 창원시 마산회원구",
|
||||
"48129": "경상남도 창원시 진해구",
|
||||
"48170": "경상남도 진주시",
|
||||
"48220": "경상남도 통영시",
|
||||
"48240": "경상남도 사천시",
|
||||
"48250": "경상남도 김해시",
|
||||
"48270": "경상남도 밀양시",
|
||||
"48310": "경상남도 거제시",
|
||||
"48330": "경상남도 양산시",
|
||||
"48720": "경상남도 의령군",
|
||||
"48730": "경상남도 함안군",
|
||||
"48740": "경상남도 창녕군",
|
||||
"48820": "경상남도 고성군",
|
||||
"48840": "경상남도 남해군",
|
||||
"48850": "경상남도 하동군",
|
||||
"48860": "경상남도 산청군",
|
||||
"48870": "경상남도 함양군",
|
||||
"48880": "경상남도 거창군",
|
||||
"48890": "경상남도 합천군",
|
||||
"50000": "제주특별자치도",
|
||||
"50110": "제주특별자치도 제주시",
|
||||
"50130": "제주특별자치도 서귀포시",
|
||||
"51000": "강원특별자치도",
|
||||
"51110": "강원특별자치도 춘천시",
|
||||
"51130": "강원특별자치도 원주시",
|
||||
"51150": "강원특별자치도 강릉시",
|
||||
"51170": "강원특별자치도 동해시",
|
||||
"51190": "강원특별자치도 태백시",
|
||||
"51210": "강원특별자치도 속초시",
|
||||
"51230": "강원특별자치도 삼척시",
|
||||
"51720": "강원특별자치도 홍천군",
|
||||
"51730": "강원특별자치도 횡성군",
|
||||
"51750": "강원특별자치도 영월군",
|
||||
"51760": "강원특별자치도 평창군",
|
||||
"51770": "강원특별자치도 정선군",
|
||||
"51780": "강원특별자치도 철원군",
|
||||
"51790": "강원특별자치도 화천군",
|
||||
"51800": "강원특별자치도 양구군",
|
||||
"51810": "강원특별자치도 인제군",
|
||||
"51820": "강원특별자치도 고성군",
|
||||
"51830": "강원특별자치도 양양군",
|
||||
"52000": "전북특별자치도",
|
||||
"52110": "전북특별자치도 전주시",
|
||||
"52111": "전북특별자치도 전주시 완산구",
|
||||
"52113": "전북특별자치도 전주시 덕진구",
|
||||
"52130": "전북특별자치도 군산시",
|
||||
"52140": "전북특별자치도 익산시",
|
||||
"52180": "전북특별자치도 정읍시",
|
||||
"52190": "전북특별자치도 남원시",
|
||||
"52210": "전북특별자치도 김제시",
|
||||
"52710": "전북특별자치도 완주군",
|
||||
"52720": "전북특별자치도 진안군",
|
||||
"52730": "전북특별자치도 무주군",
|
||||
"52740": "전북특별자치도 장수군",
|
||||
"52750": "전북특별자치도 임실군",
|
||||
"52770": "전북특별자치도 순창군",
|
||||
"52790": "전북특별자치도 고창군",
|
||||
"52800": "전북특별자치도 부안군"
|
||||
}
|
||||
33
packages/k-skill-proxy/src/region-lookup.js
Normal file
33
packages/k-skill-proxy/src/region-lookup.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// Region code lookup: resolves free-text Korean address queries to 5-digit
|
||||
// LAWD_CD codes used by MOLIT real estate APIs.
|
||||
|
||||
let regionData = null;
|
||||
|
||||
function loadRegionCodes() {
|
||||
if (!regionData) {
|
||||
const raw = require("./region-codes.json");
|
||||
regionData = Object.entries(raw).map(([lawd_cd, name]) => ({ lawd_cd, name }));
|
||||
}
|
||||
return regionData;
|
||||
}
|
||||
|
||||
function searchRegionCode(query) {
|
||||
if (!query || typeof query !== "string") return [];
|
||||
|
||||
const tokens = query.trim().split(/\s+/).filter(Boolean);
|
||||
if (tokens.length === 0) return [];
|
||||
|
||||
const entries = loadRegionCodes();
|
||||
const results = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (tokens.every((tok) => entry.name.includes(tok))) {
|
||||
results.push(entry);
|
||||
if (results.length >= 10) break;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
module.exports = { searchRegionCode, loadRegionCodes };
|
||||
|
|
@ -2,8 +2,11 @@ const crypto = require("node:crypto");
|
|||
const Fastify = require("fastify");
|
||||
const { fetchFineDustReport } = require("./airkorea");
|
||||
const { fetchWaterLevelReport } = require("./hrfco");
|
||||
const { fetchTransactions, VALID_ASSET_TYPES, VALID_DEAL_TYPES } = require("./molit");
|
||||
const { searchRegionCode } = require("./region-lookup");
|
||||
const AIR_KOREA_UPSTREAM_BASE_URL = "http://apis.data.go.kr";
|
||||
const SEOUL_OPEN_API_BASE_URL = "http://swopenapi.seoul.go.kr";
|
||||
const OPINET_API_BASE_URL = "https://www.opinet.co.kr/api";
|
||||
const ALLOWED_AIRKOREA_ROUTES = new Map([
|
||||
["MsrstnInfoInqireSvc", new Set(["getMsrstnList", "getNearbyMsrstnList", "getTMStdrCrdnt"])],
|
||||
["ArpltnInforInqireSvc", new Set(["getMsrstnAcctoRltmMesureDnsty", "getCtprvnRltmMesureDnsty"])],
|
||||
|
|
@ -44,6 +47,8 @@ function buildConfig(env = process.env) {
|
|||
airKoreaApiKey: trimOrNull(env.AIR_KOREA_OPEN_API_KEY),
|
||||
seoulOpenApiKey: trimOrNull(env.SEOUL_OPEN_API_KEY),
|
||||
hrfcoApiKey: trimOrNull(env.HRFCO_OPEN_API_KEY),
|
||||
opinetApiKey: trimOrNull(env.OPINET_API_KEY),
|
||||
molitApiKey: trimOrNull(env.DATA_GO_KR_API_KEY),
|
||||
cacheTtlMs: parseInteger(env.KSKILL_PROXY_CACHE_TTL_MS, 300000),
|
||||
rateLimitWindowMs: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_WINDOW_MS, 60000),
|
||||
rateLimitMax: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_MAX, 60)
|
||||
|
|
@ -144,6 +149,56 @@ function normalizeSeoulSubwayQuery(query) {
|
|||
};
|
||||
}
|
||||
|
||||
function normalizeOpinetAroundQuery(query) {
|
||||
const x = parseFloatValue(query.x);
|
||||
const y = parseFloatValue(query.y);
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
||||
throw new Error("Provide x and y as KATEC coordinates.");
|
||||
}
|
||||
const radius = parseInteger(query.radius, 1000);
|
||||
if (radius <= 0 || radius > 5000) {
|
||||
throw new Error("radius must be between 1 and 5000.");
|
||||
}
|
||||
const prodcd = trimOrNull(query.prodcd) || "B027";
|
||||
const sort = parseInteger(query.sort, 1);
|
||||
return { x, y, radius, prodcd, sort };
|
||||
}
|
||||
|
||||
function normalizeOpinetDetailQuery(query) {
|
||||
const id = trimOrNull(query.id);
|
||||
if (!id) {
|
||||
throw new Error("Provide id.");
|
||||
}
|
||||
return { id };
|
||||
}
|
||||
|
||||
function normalizeRealEstateQuery(query) {
|
||||
const lawdCd = trimOrNull(query.lawd_cd ?? query.lawdCd);
|
||||
if (!lawdCd || !/^\d{5}$/.test(lawdCd)) {
|
||||
throw new Error("Provide lawd_cd as a 5-digit region code.");
|
||||
}
|
||||
|
||||
const dealYmd = trimOrNull(query.deal_ymd ?? query.dealYmd);
|
||||
if (!dealYmd || !/^\d{6}$/.test(dealYmd)) {
|
||||
throw new Error("Provide deal_ymd as YYYYMM.");
|
||||
}
|
||||
|
||||
const numOfRows = parseInteger(query.num_of_rows ?? query.numOfRows, 100);
|
||||
if (numOfRows < 1 || numOfRows > 1000) {
|
||||
throw new Error("num_of_rows must be between 1 and 1000.");
|
||||
}
|
||||
|
||||
return { lawdCd, dealYmd, numOfRows };
|
||||
}
|
||||
|
||||
function normalizeRegionCodeQuery(query) {
|
||||
const q = trimOrNull(query.q ?? query.query);
|
||||
if (!q) {
|
||||
throw new Error("Provide q (region name query).");
|
||||
}
|
||||
return { q };
|
||||
}
|
||||
|
||||
function normalizeHanRiverWaterLevelQuery(query) {
|
||||
const stationName = trimOrNull(query.stationName ?? query.station_name ?? query.station);
|
||||
const stationCode = trimOrNull(query.stationCode ?? query.station_code ?? query.wlobscd);
|
||||
|
|
@ -245,6 +300,36 @@ async function proxySeoulSubwayRequest({
|
|||
};
|
||||
}
|
||||
|
||||
async function proxyOpinetRequest({ path, params, apiKey, fetchImpl = global.fetch }) {
|
||||
if (!apiKey) {
|
||||
return {
|
||||
statusCode: 503,
|
||||
contentType: "application/json; charset=utf-8",
|
||||
body: JSON.stringify({
|
||||
error: "upstream_not_configured",
|
||||
message: "OPINET_API_KEY is not configured on the proxy server."
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
const url = new URL(`${OPINET_API_BASE_URL}/${path}`);
|
||||
url.searchParams.set("out", "json");
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
url.searchParams.set("certkey", apiKey);
|
||||
|
||||
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 proxyHrfcoWaterLevelRequest({
|
||||
stationName = null,
|
||||
stationCode = null,
|
||||
|
|
@ -326,7 +411,9 @@ function buildServer({ env = process.env, provider = null } = {}) {
|
|||
upstreams: {
|
||||
airKoreaConfigured: Boolean(config.airKoreaApiKey),
|
||||
seoulOpenApiConfigured: Boolean(config.seoulOpenApiKey),
|
||||
hrfcoConfigured: Boolean(config.hrfcoApiKey)
|
||||
hrfcoConfigured: Boolean(config.hrfcoApiKey),
|
||||
opinetConfigured: Boolean(config.opinetApiKey),
|
||||
molitConfigured: Boolean(config.molitApiKey)
|
||||
},
|
||||
auth: {
|
||||
tokenRequired: false
|
||||
|
|
@ -529,6 +616,297 @@ function buildServer({ env = process.env, provider = null } = {}) {
|
|||
return payload;
|
||||
});
|
||||
|
||||
app.get("/v1/opinet/around", async (request, reply) => {
|
||||
let normalized;
|
||||
|
||||
try {
|
||||
normalized = normalizeOpinetAroundQuery(request.query || {});
|
||||
} catch (error) {
|
||||
reply.code(400);
|
||||
return {
|
||||
error: "bad_request",
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
|
||||
const cacheKey = makeCacheKey({
|
||||
route: "opinet-around",
|
||||
...normalized
|
||||
});
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
return {
|
||||
...cached,
|
||||
proxy: {
|
||||
...cached.proxy,
|
||||
cache: {
|
||||
hit: true,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const upstream = await proxyOpinetRequest({
|
||||
path: "aroundAll.do",
|
||||
params: normalized,
|
||||
apiKey: config.opinetApiKey
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
app.get("/v1/opinet/detail", async (request, reply) => {
|
||||
let normalized;
|
||||
|
||||
try {
|
||||
normalized = normalizeOpinetDetailQuery(request.query || {});
|
||||
} catch (error) {
|
||||
reply.code(400);
|
||||
return {
|
||||
error: "bad_request",
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
|
||||
const cacheKey = makeCacheKey({
|
||||
route: "opinet-detail",
|
||||
...normalized
|
||||
});
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
return {
|
||||
...cached,
|
||||
proxy: {
|
||||
...cached.proxy,
|
||||
cache: {
|
||||
hit: true,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const upstream = await proxyOpinetRequest({
|
||||
path: "detailById.do",
|
||||
params: normalized,
|
||||
apiKey: config.opinetApiKey
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
|
||||
app.get("/v1/real-estate/region-code", async (request, reply) => {
|
||||
let normalized;
|
||||
|
||||
try {
|
||||
normalized = normalizeRegionCodeQuery(request.query || {});
|
||||
} catch (error) {
|
||||
reply.code(400);
|
||||
return {
|
||||
error: "bad_request",
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
|
||||
const cacheKey = makeCacheKey({
|
||||
route: "real-estate-region-code",
|
||||
...normalized
|
||||
});
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
return {
|
||||
...cached,
|
||||
proxy: {
|
||||
...cached.proxy,
|
||||
cache: {
|
||||
hit: true,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const results = searchRegionCode(normalized.q);
|
||||
const payload = {
|
||||
results,
|
||||
query: normalized.q,
|
||||
proxy: {
|
||||
name: config.proxyName,
|
||||
cache: {
|
||||
hit: false,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
},
|
||||
requested_at: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
cache.set(cacheKey, payload, config.cacheTtlMs);
|
||||
return payload;
|
||||
});
|
||||
|
||||
app.get("/v1/real-estate/:assetType/:dealType", async (request, reply) => {
|
||||
const { assetType, dealType } = request.params;
|
||||
|
||||
if (!VALID_ASSET_TYPES.has(assetType)) {
|
||||
reply.code(404);
|
||||
return {
|
||||
error: "not_found",
|
||||
message: `Unknown asset type: ${assetType}. Valid: apartment, officetel, villa, single-house, commercial`
|
||||
};
|
||||
}
|
||||
|
||||
if (!VALID_DEAL_TYPES.has(dealType)) {
|
||||
reply.code(404);
|
||||
return {
|
||||
error: "not_found",
|
||||
message: `Unknown deal type: ${dealType}. Valid: trade, rent`
|
||||
};
|
||||
}
|
||||
|
||||
if (assetType === "commercial" && dealType === "rent") {
|
||||
reply.code(404);
|
||||
return {
|
||||
error: "not_found",
|
||||
message: "commercial/rent is not available. Only commercial/trade is supported."
|
||||
};
|
||||
}
|
||||
|
||||
let normalized;
|
||||
|
||||
try {
|
||||
normalized = normalizeRealEstateQuery(request.query || {});
|
||||
} catch (error) {
|
||||
reply.code(400);
|
||||
return {
|
||||
error: "bad_request",
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
|
||||
const cacheKey = makeCacheKey({
|
||||
route: "real-estate",
|
||||
assetType,
|
||||
dealType,
|
||||
...normalized
|
||||
});
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
return {
|
||||
...cached,
|
||||
proxy: {
|
||||
...cached.proxy,
|
||||
cache: {
|
||||
hit: true,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.molitApiKey) {
|
||||
reply.code(503);
|
||||
return {
|
||||
error: "upstream_not_configured",
|
||||
message: "DATA_GO_KR_API_KEY is not configured on the proxy server.",
|
||||
proxy: {
|
||||
name: config.proxyName,
|
||||
cache: {
|
||||
hit: false,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const result = await fetchTransactions({
|
||||
assetType,
|
||||
dealType,
|
||||
lawdCd: normalized.lawdCd,
|
||||
dealYmd: normalized.dealYmd,
|
||||
numOfRows: normalized.numOfRows,
|
||||
serviceKey: config.molitApiKey
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
reply.code(502);
|
||||
return {
|
||||
...result,
|
||||
proxy: {
|
||||
name: config.proxyName,
|
||||
cache: {
|
||||
hit: false,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
},
|
||||
requested_at: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...result,
|
||||
query: {
|
||||
asset_type: assetType,
|
||||
deal_type: dealType,
|
||||
lawd_cd: normalized.lawdCd,
|
||||
deal_ymd: normalized.dealYmd
|
||||
},
|
||||
proxy: {
|
||||
name: config.proxyName,
|
||||
cache: {
|
||||
hit: false,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
},
|
||||
requested_at: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
cache.set(cacheKey, payload, config.cacheTtlMs);
|
||||
return payload;
|
||||
});
|
||||
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
request.log.error(error);
|
||||
|
|
@ -571,9 +949,14 @@ module.exports = {
|
|||
buildServer,
|
||||
normalizeFineDustQuery,
|
||||
normalizeHanRiverWaterLevelQuery,
|
||||
normalizeOpinetAroundQuery,
|
||||
normalizeOpinetDetailQuery,
|
||||
normalizeRealEstateQuery,
|
||||
normalizeRegionCodeQuery,
|
||||
normalizeSeoulSubwayQuery,
|
||||
proxyAirKoreaRequest,
|
||||
proxyHrfcoWaterLevelRequest,
|
||||
proxyOpinetRequest,
|
||||
proxySeoulSubwayRequest,
|
||||
startServer
|
||||
};
|
||||
|
|
|
|||
349
packages/k-skill-proxy/test/molit.test.js
Normal file
349
packages/k-skill-proxy/test/molit.test.js
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
parseXmlItems,
|
||||
extractTag,
|
||||
normalizeTradeItem,
|
||||
normalizeRentItem,
|
||||
computeTradeSummary,
|
||||
computeRentSummary,
|
||||
fetchTransactions,
|
||||
median,
|
||||
} = require("../src/molit");
|
||||
|
||||
const SAMPLE_APT_TRADE_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<response>
|
||||
<header><resultCode>000</resultCode><resultMsg>NORMAL SERVICE.</resultMsg></header>
|
||||
<body>
|
||||
<items>
|
||||
<item>
|
||||
<aptNm>래미안</aptNm>
|
||||
<umdNm>반포동</umdNm>
|
||||
<excluUseAr>84.99</excluUseAr>
|
||||
<floor>12</floor>
|
||||
<dealAmount> 245,000</dealAmount>
|
||||
<dealYear>2024</dealYear>
|
||||
<dealMonth>3</dealMonth>
|
||||
<dealDay>15</dealDay>
|
||||
<buildYear>2009</buildYear>
|
||||
<dealingGbn>중개거래</dealingGbn>
|
||||
<cdealType></cdealType>
|
||||
</item>
|
||||
<item>
|
||||
<aptNm>래미안</aptNm>
|
||||
<umdNm>반포동</umdNm>
|
||||
<excluUseAr>84.99</excluUseAr>
|
||||
<floor>5</floor>
|
||||
<dealAmount> 200,000</dealAmount>
|
||||
<dealYear>2024</dealYear>
|
||||
<dealMonth>3</dealMonth>
|
||||
<dealDay>20</dealDay>
|
||||
<buildYear>2009</buildYear>
|
||||
<dealingGbn>직거래</dealingGbn>
|
||||
<cdealType>O</cdealType>
|
||||
</item>
|
||||
<item>
|
||||
<aptNm>아크로리버</aptNm>
|
||||
<umdNm>반포동</umdNm>
|
||||
<excluUseAr>59.96</excluUseAr>
|
||||
<floor>3</floor>
|
||||
<dealAmount> 180,000</dealAmount>
|
||||
<dealYear>2024</dealYear>
|
||||
<dealMonth>3</dealMonth>
|
||||
<dealDay>22</dealDay>
|
||||
<buildYear>2016</buildYear>
|
||||
<dealingGbn>중개거래</dealingGbn>
|
||||
<cdealType></cdealType>
|
||||
</item>
|
||||
</items>
|
||||
<totalCount>3</totalCount>
|
||||
</body>
|
||||
</response>`;
|
||||
|
||||
const SAMPLE_APT_RENT_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<response>
|
||||
<header><resultCode>000</resultCode><resultMsg>NORMAL SERVICE.</resultMsg></header>
|
||||
<body>
|
||||
<items>
|
||||
<item>
|
||||
<aptNm>래미안</aptNm>
|
||||
<umdNm>반포동</umdNm>
|
||||
<excluUseAr>84.99</excluUseAr>
|
||||
<floor>12</floor>
|
||||
<deposit> 80,000</deposit>
|
||||
<monthlyRent>0</monthlyRent>
|
||||
<contractType>신규</contractType>
|
||||
<dealYear>2024</dealYear>
|
||||
<dealMonth>3</dealMonth>
|
||||
<dealDay>10</dealDay>
|
||||
<buildYear>2009</buildYear>
|
||||
<cdealType></cdealType>
|
||||
</item>
|
||||
<item>
|
||||
<aptNm>아크로리버</aptNm>
|
||||
<umdNm>반포동</umdNm>
|
||||
<excluUseAr>59.96</excluUseAr>
|
||||
<floor>5</floor>
|
||||
<deposit> 10,000</deposit>
|
||||
<monthlyRent> 150</monthlyRent>
|
||||
<contractType>갱신</contractType>
|
||||
<dealYear>2024</dealYear>
|
||||
<dealMonth>3</dealMonth>
|
||||
<dealDay>15</dealDay>
|
||||
<buildYear>2016</buildYear>
|
||||
<cdealType></cdealType>
|
||||
</item>
|
||||
</items>
|
||||
<totalCount>2</totalCount>
|
||||
</body>
|
||||
</response>`;
|
||||
|
||||
const ERROR_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<response>
|
||||
<header><resultCode>030</resultCode><resultMsg>등록되지 않은 서비스키입니다.</resultMsg></header>
|
||||
</response>`;
|
||||
|
||||
test("parseXmlItems extracts items and totalCount from valid XML", () => {
|
||||
const result = parseXmlItems(SAMPLE_APT_TRADE_XML);
|
||||
assert.equal(result.error, undefined);
|
||||
assert.equal(result.totalCount, 3);
|
||||
assert.equal(result.items.length, 3);
|
||||
});
|
||||
|
||||
test("parseXmlItems returns error for non-000 resultCode", () => {
|
||||
const result = parseXmlItems(ERROR_XML);
|
||||
assert.equal(result.error, "molit_api_030");
|
||||
assert.ok(result.message.includes("등록되지 않은 서비스키"));
|
||||
});
|
||||
|
||||
test("parseXmlItems returns error for missing resultCode", () => {
|
||||
const result = parseXmlItems("<response><body></body></response>");
|
||||
assert.equal(result.error, "parse_error");
|
||||
});
|
||||
|
||||
test("extractTag extracts trimmed value", () => {
|
||||
const xml = "<aptNm> 래미안 퍼스티지 </aptNm>";
|
||||
assert.equal(extractTag(xml, "aptNm"), "래미안 퍼스티지");
|
||||
});
|
||||
|
||||
test("extractTag returns empty string for missing tag", () => {
|
||||
assert.equal(extractTag("<foo>bar</foo>", "missing"), "");
|
||||
});
|
||||
|
||||
test("normalizeTradeItem parses apartment trade correctly", () => {
|
||||
const parsed = parseXmlItems(SAMPLE_APT_TRADE_XML);
|
||||
const item = normalizeTradeItem(parsed.items[0], "apartment");
|
||||
assert.equal(item.name, "래미안");
|
||||
assert.equal(item.district, "반포동");
|
||||
assert.equal(item.area_m2, 84.99);
|
||||
assert.equal(item.floor, 12);
|
||||
assert.equal(item.price_10k, 245000);
|
||||
assert.equal(item.deal_date, "2024-03-15");
|
||||
assert.equal(item.build_year, 2009);
|
||||
assert.equal(item.deal_type, "중개거래");
|
||||
});
|
||||
|
||||
test("normalizeTradeItem filters cancelled deals", () => {
|
||||
const parsed = parseXmlItems(SAMPLE_APT_TRADE_XML);
|
||||
const item = normalizeTradeItem(parsed.items[1], "apartment");
|
||||
assert.equal(item, null);
|
||||
});
|
||||
|
||||
test("normalizeTradeItem uses offiNm for officetel", () => {
|
||||
const xml = `<offiNm>오피스텔A</offiNm><umdNm>역삼동</umdNm><excluUseAr>33.5</excluUseAr>
|
||||
<floor>7</floor><dealAmount>50,000</dealAmount><dealYear>2024</dealYear>
|
||||
<dealMonth>1</dealMonth><dealDay>5</dealDay><buildYear>2020</buildYear>
|
||||
<dealingGbn>중개거래</dealingGbn><cdealType></cdealType>`;
|
||||
const item = normalizeTradeItem(xml, "officetel");
|
||||
assert.equal(item.name, "오피스텔A");
|
||||
});
|
||||
|
||||
test("normalizeTradeItem uses mhouseNm and houseType for villa", () => {
|
||||
const xml = `<mhouseNm>빌라B</mhouseNm><umdNm>신림동</umdNm><excluUseAr>45.0</excluUseAr>
|
||||
<floor>3</floor><dealAmount>30,000</dealAmount><dealYear>2024</dealYear>
|
||||
<dealMonth>2</dealMonth><dealDay>10</dealDay><buildYear>2015</buildYear>
|
||||
<dealingGbn>직거래</dealingGbn><cdealType></cdealType><houseType>다세대</houseType>`;
|
||||
const item = normalizeTradeItem(xml, "villa");
|
||||
assert.equal(item.name, "빌라B");
|
||||
assert.equal(item.houseType, "다세대");
|
||||
});
|
||||
|
||||
test("normalizeTradeItem uses totalFloorAr and floor=0 for single-house", () => {
|
||||
const xml = `<umdNm>수유동</umdNm><totalFloorAr>120.5</totalFloorAr>
|
||||
<dealAmount>70,000</dealAmount><dealYear>2024</dealYear>
|
||||
<dealMonth>4</dealMonth><dealDay>1</dealDay><buildYear>1990</buildYear>
|
||||
<dealingGbn>중개거래</dealingGbn><cdealType></cdealType><houseType>단독</houseType>`;
|
||||
const item = normalizeTradeItem(xml, "single-house");
|
||||
assert.equal(item.name, "");
|
||||
assert.equal(item.area_m2, 120.5);
|
||||
assert.equal(item.floor, 0);
|
||||
assert.equal(item.houseType, "단독");
|
||||
});
|
||||
|
||||
test("normalizeTradeItem handles commercial with lowercase cdealtype", () => {
|
||||
const xml = `<buildingType>업무시설</buildingType><buildingUse>오피스</buildingUse>
|
||||
<landUse>상업지역</landUse><umdNm>역삼동</umdNm><buildingAr>200.0</buildingAr>
|
||||
<floor>10</floor><dealAmount>500,000</dealAmount><dealYear>2024</dealYear>
|
||||
<dealMonth>5</dealMonth><dealDay>20</dealDay><buildYear>2018</buildYear>
|
||||
<dealingGbn>중개거래</dealingGbn><cdealtype></cdealtype><shareDealingType></shareDealingType>`;
|
||||
const item = normalizeTradeItem(xml, "commercial");
|
||||
assert.equal(item.buildingType, "업무시설");
|
||||
assert.equal(item.area_m2, 200.0);
|
||||
assert.equal(item.price_10k, 500000);
|
||||
});
|
||||
|
||||
test("normalizeRentItem parses apartment rent correctly", () => {
|
||||
const parsed = parseXmlItems(SAMPLE_APT_RENT_XML);
|
||||
const item = normalizeRentItem(parsed.items[0], "apartment");
|
||||
assert.equal(item.name, "래미안");
|
||||
assert.equal(item.deposit_10k, 80000);
|
||||
assert.equal(item.monthly_rent_10k, 0);
|
||||
assert.equal(item.contract_type, "신규");
|
||||
assert.equal(item.deal_date, "2024-03-10");
|
||||
});
|
||||
|
||||
test("normalizeRentItem parses monthly rent correctly", () => {
|
||||
const parsed = parseXmlItems(SAMPLE_APT_RENT_XML);
|
||||
const item = normalizeRentItem(parsed.items[1], "apartment");
|
||||
assert.equal(item.deposit_10k, 10000);
|
||||
assert.equal(item.monthly_rent_10k, 150);
|
||||
});
|
||||
|
||||
test("median computes correctly for odd-length array", () => {
|
||||
assert.equal(median([3, 1, 2]), 2);
|
||||
});
|
||||
|
||||
test("median computes correctly for even-length array", () => {
|
||||
assert.equal(median([1, 2, 3, 4]), 2);
|
||||
});
|
||||
|
||||
test("median returns 0 for empty array", () => {
|
||||
assert.equal(median([]), 0);
|
||||
});
|
||||
|
||||
test("computeTradeSummary computes stats correctly", () => {
|
||||
const items = [
|
||||
{ price_10k: 100000 },
|
||||
{ price_10k: 200000 },
|
||||
{ price_10k: 300000 },
|
||||
];
|
||||
const summary = computeTradeSummary(items);
|
||||
assert.equal(summary.median_price_10k, 200000);
|
||||
assert.equal(summary.min_price_10k, 100000);
|
||||
assert.equal(summary.max_price_10k, 300000);
|
||||
assert.equal(summary.sample_count, 3);
|
||||
});
|
||||
|
||||
test("computeTradeSummary returns zeros for empty array", () => {
|
||||
const summary = computeTradeSummary([]);
|
||||
assert.equal(summary.sample_count, 0);
|
||||
assert.equal(summary.median_price_10k, 0);
|
||||
});
|
||||
|
||||
test("computeRentSummary computes deposit and rent stats", () => {
|
||||
const items = [
|
||||
{ deposit_10k: 50000, monthly_rent_10k: 0 },
|
||||
{ deposit_10k: 10000, monthly_rent_10k: 100 },
|
||||
{ deposit_10k: 30000, monthly_rent_10k: 50 },
|
||||
];
|
||||
const summary = computeRentSummary(items);
|
||||
assert.equal(summary.median_deposit_10k, 30000);
|
||||
assert.equal(summary.min_deposit_10k, 10000);
|
||||
assert.equal(summary.max_deposit_10k, 50000);
|
||||
assert.equal(summary.monthly_rent_avg_10k, 50);
|
||||
assert.equal(summary.sample_count, 3);
|
||||
});
|
||||
|
||||
test("fetchTransactions returns error for invalid endpoint", async () => {
|
||||
const result = await fetchTransactions({
|
||||
assetType: "unknown",
|
||||
dealType: "trade",
|
||||
lawdCd: "11680",
|
||||
dealYmd: "202403",
|
||||
serviceKey: "key",
|
||||
});
|
||||
assert.equal(result.error, "invalid_endpoint");
|
||||
});
|
||||
|
||||
test("fetchTransactions parses full XML pipeline", async () => {
|
||||
const mockFetch = async () => ({
|
||||
ok: true,
|
||||
text: async () => SAMPLE_APT_TRADE_XML,
|
||||
});
|
||||
|
||||
const result = await fetchTransactions({
|
||||
assetType: "apartment",
|
||||
dealType: "trade",
|
||||
lawdCd: "11680",
|
||||
dealYmd: "202403",
|
||||
serviceKey: "test-key",
|
||||
fetchImpl: mockFetch,
|
||||
});
|
||||
|
||||
assert.equal(result.error, undefined);
|
||||
assert.equal(result.items.length, 2); // 1 cancelled filtered out
|
||||
assert.equal(result.total_count, 3);
|
||||
assert.equal(result.filtered_count, 2);
|
||||
assert.equal(result.items[0].name, "래미안");
|
||||
assert.equal(result.items[0].price_10k, 245000);
|
||||
assert.equal(result.summary.sample_count, 2);
|
||||
});
|
||||
|
||||
test("fetchTransactions handles rent XML", async () => {
|
||||
const mockFetch = async () => ({
|
||||
ok: true,
|
||||
text: async () => SAMPLE_APT_RENT_XML,
|
||||
});
|
||||
|
||||
const result = await fetchTransactions({
|
||||
assetType: "apartment",
|
||||
dealType: "rent",
|
||||
lawdCd: "11680",
|
||||
dealYmd: "202403",
|
||||
serviceKey: "test-key",
|
||||
fetchImpl: mockFetch,
|
||||
});
|
||||
|
||||
assert.equal(result.error, undefined);
|
||||
assert.equal(result.items.length, 2);
|
||||
assert.equal(result.items[0].deposit_10k, 80000);
|
||||
assert.ok(result.summary.median_deposit_10k > 0);
|
||||
});
|
||||
|
||||
test("fetchTransactions returns error for upstream failure", async () => {
|
||||
const mockFetch = async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => "Internal Server Error",
|
||||
});
|
||||
|
||||
const result = await fetchTransactions({
|
||||
assetType: "apartment",
|
||||
dealType: "trade",
|
||||
lawdCd: "11680",
|
||||
dealYmd: "202403",
|
||||
serviceKey: "test-key",
|
||||
fetchImpl: mockFetch,
|
||||
});
|
||||
|
||||
assert.equal(result.error, "upstream_error");
|
||||
});
|
||||
|
||||
test("fetchTransactions returns error for API error code", async () => {
|
||||
const mockFetch = async () => ({
|
||||
ok: true,
|
||||
text: async () => ERROR_XML,
|
||||
});
|
||||
|
||||
const result = await fetchTransactions({
|
||||
assetType: "apartment",
|
||||
dealType: "trade",
|
||||
lawdCd: "11680",
|
||||
dealYmd: "202403",
|
||||
serviceKey: "test-key",
|
||||
fetchImpl: mockFetch,
|
||||
});
|
||||
|
||||
assert.equal(result.error, "molit_api_030");
|
||||
});
|
||||
39
packages/k-skill-proxy/test/region-lookup.test.js
Normal file
39
packages/k-skill-proxy/test/region-lookup.test.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { searchRegionCode } = require("../src/region-lookup");
|
||||
|
||||
test("searchRegionCode finds by single token", () => {
|
||||
const results = searchRegionCode("강남구");
|
||||
assert.ok(results.length > 0);
|
||||
assert.ok(results.some((r) => r.lawd_cd === "11680"));
|
||||
assert.ok(results.every((r) => r.name.includes("강남구")));
|
||||
});
|
||||
|
||||
test("searchRegionCode finds by multiple tokens", () => {
|
||||
const results = searchRegionCode("서울 강남구");
|
||||
assert.ok(results.length > 0);
|
||||
assert.ok(results.every((r) => r.name.includes("서울") && r.name.includes("강남구")));
|
||||
});
|
||||
|
||||
test("searchRegionCode returns empty for no match", () => {
|
||||
const results = searchRegionCode("존재하지않는지역");
|
||||
assert.equal(results.length, 0);
|
||||
});
|
||||
|
||||
test("searchRegionCode returns empty for empty/null input", () => {
|
||||
assert.equal(searchRegionCode("").length, 0);
|
||||
assert.equal(searchRegionCode(null).length, 0);
|
||||
assert.equal(searchRegionCode(undefined).length, 0);
|
||||
});
|
||||
|
||||
test("searchRegionCode returns at most 10 results", () => {
|
||||
const results = searchRegionCode("시");
|
||||
assert.ok(results.length <= 10);
|
||||
});
|
||||
|
||||
test("searchRegionCode finds 세종특별자치시", () => {
|
||||
const results = searchRegionCode("세종");
|
||||
assert.ok(results.length > 0);
|
||||
assert.ok(results.some((r) => r.name.includes("세종")));
|
||||
});
|
||||
|
|
@ -586,3 +586,208 @@ test("proxyHrfcoWaterLevelRequest injects API key and resolves station code path
|
|||
assert.match(calledUrls[0], /\/test-hrfco-key\/waterlevel\/info\.json$/);
|
||||
assert.match(calledUrls[1], /\/test-hrfco-key\/waterlevel\/list\/10M\/1018683\.json$/);
|
||||
});
|
||||
|
||||
const SAMPLE_APT_TRADE_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<response>
|
||||
<header><resultCode>000</resultCode><resultMsg>NORMAL SERVICE.</resultMsg></header>
|
||||
<body>
|
||||
<items>
|
||||
<item>
|
||||
<aptNm>래미안</aptNm><umdNm>반포동</umdNm><excluUseAr>84.99</excluUseAr>
|
||||
<floor>12</floor><dealAmount> 245,000</dealAmount>
|
||||
<dealYear>2024</dealYear><dealMonth>3</dealMonth><dealDay>15</dealDay>
|
||||
<buildYear>2009</buildYear><dealingGbn>중개거래</dealingGbn><cdealType></cdealType>
|
||||
</item>
|
||||
</items>
|
||||
<totalCount>1</totalCount>
|
||||
</body>
|
||||
</response>`;
|
||||
|
||||
test("real estate region-code endpoint returns matching codes", async (t) => {
|
||||
const app = buildServer();
|
||||
|
||||
t.after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/real-estate/region-code?q=%EA%B0%95%EB%82%A8%EA%B5%AC"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
const body = response.json();
|
||||
assert.ok(body.results.length > 0);
|
||||
assert.ok(body.results.some((r) => r.lawd_cd === "11680"));
|
||||
assert.equal(body.proxy.cache.hit, false);
|
||||
});
|
||||
|
||||
test("real estate region-code endpoint returns 400 for missing query", async (t) => {
|
||||
const app = buildServer();
|
||||
|
||||
t.after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/real-estate/region-code"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.equal(response.json().error, "bad_request");
|
||||
});
|
||||
|
||||
test("real estate transaction endpoint returns 503 without API key", async (t) => {
|
||||
const app = buildServer();
|
||||
|
||||
t.after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/real-estate/apartment/trade?lawd_cd=11680&deal_ymd=202403"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 503);
|
||||
assert.equal(response.json().error, "upstream_not_configured");
|
||||
});
|
||||
|
||||
test("real estate transaction endpoint returns 404 for invalid asset type", async (t) => {
|
||||
const app = buildServer({
|
||||
env: { DATA_GO_KR_API_KEY: "test-key" }
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/real-estate/mansion/trade?lawd_cd=11680&deal_ymd=202403"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 404);
|
||||
});
|
||||
|
||||
test("real estate transaction endpoint returns 404 for commercial/rent", async (t) => {
|
||||
const app = buildServer({
|
||||
env: { DATA_GO_KR_API_KEY: "test-key" }
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/real-estate/commercial/rent?lawd_cd=11680&deal_ymd=202403"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 404);
|
||||
});
|
||||
|
||||
test("real estate transaction endpoint returns 400 for invalid lawd_cd", async (t) => {
|
||||
const app = buildServer({
|
||||
env: { DATA_GO_KR_API_KEY: "test-key" }
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/real-estate/apartment/trade?lawd_cd=abc&deal_ymd=202403"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 400);
|
||||
});
|
||||
|
||||
test("real estate transaction endpoint fetches and returns parsed data", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async () => {
|
||||
return new Response(SAMPLE_APT_TRADE_XML, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/xml;charset=UTF-8" }
|
||||
});
|
||||
};
|
||||
|
||||
const app = buildServer({
|
||||
env: { DATA_GO_KR_API_KEY: "test-key" }
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/real-estate/apartment/trade?lawd_cd=11680&deal_ymd=202403"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
const body = response.json();
|
||||
assert.equal(body.items.length, 1);
|
||||
assert.equal(body.items[0].name, "래미안");
|
||||
assert.equal(body.items[0].price_10k, 245000);
|
||||
assert.equal(body.query.asset_type, "apartment");
|
||||
assert.equal(body.query.deal_type, "trade");
|
||||
assert.equal(body.proxy.cache.hit, false);
|
||||
});
|
||||
|
||||
test("real estate transaction endpoint caches successful responses", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
let fetchCalls = 0;
|
||||
global.fetch = async () => {
|
||||
fetchCalls += 1;
|
||||
return new Response(SAMPLE_APT_TRADE_XML, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/xml;charset=UTF-8" }
|
||||
});
|
||||
};
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
DATA_GO_KR_API_KEY: "test-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/real-estate/apartment/trade?lawd_cd=11680&deal_ymd=202403"
|
||||
});
|
||||
const second = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/real-estate/apartment/trade?lawd_cd=11680&deal_ymd=202403"
|
||||
});
|
||||
|
||||
assert.equal(first.json().proxy.cache.hit, false);
|
||||
assert.equal(second.json().proxy.cache.hit, true);
|
||||
assert.equal(fetchCalls, 1);
|
||||
});
|
||||
|
||||
test("health endpoint reports molitConfigured status", async (t) => {
|
||||
const app = buildServer({
|
||||
env: { DATA_GO_KR_API_KEY: "test-key" }
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/health"
|
||||
});
|
||||
|
||||
assert.equal(response.json().upstreams.molitConfigured, true);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
name: real-estate-search
|
||||
description: Use tae0y's real-estate-mcp for Korean apartment/officetel/villa/single-house real transaction price and rent lookups. If no hosted endpoint is available, self-host the upstream server with Cloudflare Tunnel and launchd.
|
||||
description: Korean apartment/officetel/villa/single-house real transaction price and rent lookups via k-skill-proxy. Based on tae0y's real-estate-mcp and MOLIT public data APIs.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: real-estate
|
||||
|
|
@ -12,216 +12,171 @@ metadata:
|
|||
|
||||
## What this skill does
|
||||
|
||||
한국 부동산 실거래가/전월세 조회가 필요할 때 **upstream `real-estate-mcp`**(`https://github.com/tae0y/real-estate-mcp/tree/main`)를 그대로 사용한다.
|
||||
이 저장소는 upstream 소스 코드를 vendoring 하지 않고, 연결/운영 가이드만 제공한다.
|
||||
|
||||
대표 도구:
|
||||
|
||||
- 아파트 매매 실거래가: `get_apartment_trades`
|
||||
- 아파트 전월세: `get_apartment_rent`
|
||||
- 오피스텔 매매/전월세: `get_officetel_trades`, `get_officetel_rent`
|
||||
- 연립다세대 매매/전월세: `get_villa_trades`, `get_villa_rent`
|
||||
- 단독/다가구 매매/전월세: `get_single_house_trades`, `get_single_house_rent`
|
||||
- 상업업무용 매매: `get_commercial_trade`
|
||||
- 청약홈 분양/당첨: `get_apt_subscription_info`, `get_apt_subscription_results`
|
||||
- 공공경매/온비드 입찰결과: `get_public_auction_items`, `get_public_auction_item_detail` (`⚠️ WIP`, upstream README 기준)
|
||||
- 지역코드 조회: `get_region_code`
|
||||
기본적으로 `https://k-skill-proxy.nomadamas.org/v1/real-estate/...` 로 요청해서 한국 부동산 실거래가/전월세 데이터를 조회한다. 국토교통부(MOLIT) 실거래가 신고 데이터를 기반으로 한다.
|
||||
|
||||
## When to use
|
||||
|
||||
- "잠실 리센츠 2024년 매매 실거래가 찾아줘"
|
||||
- "마포구 아파트 전세 실거래가 보여줘"
|
||||
- "성수동 오피스텔 월세 실거래 데이터 볼래"
|
||||
- "세종시 청약 결과 찾아줘"
|
||||
- "실거래가 조회용 한국 부동산 MCP 붙여줘"
|
||||
- "강남구 연립다세대 매매 실거래가"
|
||||
- "용산구 상업업무용 건물 거래 내역"
|
||||
|
||||
## When not to use
|
||||
|
||||
- 해외 부동산 시세/거래 조회
|
||||
- 실거래가가 아닌 민간 호가/매물 비교만 필요한 경우
|
||||
- 세금/등기/중개 법률자문처럼 판단이 필요한 경우
|
||||
- 이 저장소 안에 부동산 데이터 수집기나 새 서버 코드를 추가하려는 경우
|
||||
- 청약홈 분양/당첨 조회 (아직 미지원)
|
||||
|
||||
## Inputs
|
||||
|
||||
- `q`: 지역명 (region-code endpoint, 예: `"서울 강남구"`, `"마포구"`)
|
||||
- `lawd_cd`: 5자리 법정동 코드 (transaction endpoint, 예: `"11680"`)
|
||||
- `deal_ymd`: 6자리 거래년월 YYYYMM (예: `"202403"`)
|
||||
- `num_of_rows`: 조회 건수 (기본 100, 최대 1000)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- 인터넷 연결
|
||||
- `uv`
|
||||
- MCP 클라이언트(Codex CLI, Claude Desktop 등)
|
||||
- 공공데이터포털 API key (`DATA_GO_KR_API_KEY`)
|
||||
- upstream clone: `https://github.com/tae0y/real-estate-mcp/tree/main`
|
||||
없음. 사용자는 별도 API key를 준비할 필요가 없다. upstream key는 proxy 서버에서만 주입한다.
|
||||
|
||||
`DATA_GO_KR_API_KEY` 하나만 넣어도 기본 부동산 조회는 시작할 수 있다.
|
||||
청약홈/온비드 키를 분리하고 싶으면 upstream 문서대로 `ODCLOUD_API_KEY`, `ODCLOUD_SERVICE_KEY`, `ONBID_API_KEY` 를 추가한다.
|
||||
다만 `get_public_auction_items`, `get_public_auction_item_detail` 는 2026-04-05 기준 upstream README 에서 아직 `⚠️ WIP` 로 표시돼 있으니, production-ready 라고 단정하지 않고 preview 성격으로만 안내한다.
|
||||
## Default path
|
||||
|
||||
## Codex CLI setup (stdio)
|
||||
추가 client API 레이어는 불필요하다. 그냥 프록시 서버에 HTTP 요청만 넣으면 된다.
|
||||
|
||||
로컬에서 가장 빠른 기본 경로는 Codex CLI stdio 연결이다.
|
||||
`KSKILL_PROXY_BASE_URL` 환경변수가 있으면 그 값을 사용하고, 없으면 기본 경로 `https://k-skill-proxy.nomadamas.org` 를 사용한다.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/tae0y/real-estate-mcp.git
|
||||
cd real-estate-mcp
|
||||
## Supported endpoints
|
||||
|
||||
codex mcp add real-estate \
|
||||
--env DATA_GO_KR_API_KEY=your_api_key_here \
|
||||
-- uv run --directory /path/to/real-estate-mcp \
|
||||
python src/real_estate/mcp_server/server.py
|
||||
### 지역코드 조회
|
||||
|
||||
codex mcp list
|
||||
codex mcp get real-estate
|
||||
```
|
||||
GET /v1/real-estate/region-code?q={지역명}
|
||||
```
|
||||
|
||||
## Claude Desktop setup (stdio)
|
||||
### 실거래가/전월세 조회
|
||||
|
||||
```
|
||||
GET /v1/real-estate/:assetType/:dealType?lawd_cd={코드}&deal_ymd={년월}
|
||||
```
|
||||
|
||||
| assetType | dealType | 설명 |
|
||||
|---|---|---|
|
||||
| `apartment` | `trade` | 아파트 매매 |
|
||||
| `apartment` | `rent` | 아파트 전월세 |
|
||||
| `officetel` | `trade` | 오피스텔 매매 |
|
||||
| `officetel` | `rent` | 오피스텔 전월세 |
|
||||
| `villa` | `trade` | 연립다세대 매매 |
|
||||
| `villa` | `rent` | 연립다세대 전월세 |
|
||||
| `single-house` | `trade` | 단독/다가구 매매 |
|
||||
| `single-house` | `rent` | 단독/다가구 전월세 |
|
||||
| `commercial` | `trade` | 상업업무용 매매 |
|
||||
|
||||
`commercial/rent`는 지원하지 않는다.
|
||||
|
||||
## Example requests
|
||||
|
||||
지역코드 조회:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/real-estate/region-code' \
|
||||
--data-urlencode 'q=강남구'
|
||||
```
|
||||
|
||||
아파트 매매 실거래가 조회:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/real-estate/apartment/trade' \
|
||||
--data-urlencode 'lawd_cd=11680' \
|
||||
--data-urlencode 'deal_ymd=202403'
|
||||
```
|
||||
|
||||
오피스텔 전월세 조회:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/real-estate/officetel/rent' \
|
||||
--data-urlencode 'lawd_cd=11680' \
|
||||
--data-urlencode 'deal_ymd=202403'
|
||||
```
|
||||
|
||||
## Response shape
|
||||
|
||||
### 지역코드 응답
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"real-estate": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"run",
|
||||
"--directory", "/path/to/real-estate-mcp",
|
||||
"python", "src/real_estate/mcp_server/server.py"
|
||||
],
|
||||
"env": {
|
||||
"DATA_GO_KR_API_KEY": "your_api_key_here"
|
||||
}
|
||||
}
|
||||
}
|
||||
"results": [
|
||||
{ "lawd_cd": "11680", "name": "서울특별시 강남구" }
|
||||
],
|
||||
"query": "강남구",
|
||||
"proxy": { "name": "k-skill-proxy", "cache": { "hit": false, "ttl_ms": 300000 } }
|
||||
}
|
||||
```
|
||||
|
||||
## Shared HTTP setup
|
||||
|
||||
여러 클라이언트가 같이 붙어야 하면 upstream HTTP 모드를 사용한다.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/tae0y/real-estate-mcp.git
|
||||
cd real-estate-mcp
|
||||
cp .env.example .env
|
||||
printf 'DATA_GO_KR_API_KEY=your_api_key_here\n' >> .env
|
||||
uv run real-estate-mcp --transport http --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
Codex CLI/Claude Desktop 에는 HTTP URL을 등록한다.
|
||||
|
||||
```bash
|
||||
codex mcp add real-estate --url http://127.0.0.1:8000/mcp
|
||||
```
|
||||
### 매매 실거래가 응답
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"real-estate": {
|
||||
"url": "http://127.0.0.1:8000/mcp"
|
||||
"items": [
|
||||
{
|
||||
"name": "래미안 퍼스티지",
|
||||
"district": "반포동",
|
||||
"area_m2": 84.99,
|
||||
"floor": 12,
|
||||
"price_10k": 245000,
|
||||
"deal_date": "2024-03-15",
|
||||
"build_year": 2009,
|
||||
"deal_type": "중개거래"
|
||||
}
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"median_price_10k": 230000,
|
||||
"min_price_10k": 180000,
|
||||
"max_price_10k": 310000,
|
||||
"sample_count": 42
|
||||
},
|
||||
"query": { "asset_type": "apartment", "deal_type": "trade", "lawd_cd": "11680", "deal_ymd": "202403" },
|
||||
"proxy": { "name": "k-skill-proxy", "cache": { "hit": false, "ttl_ms": 300000 } }
|
||||
}
|
||||
```
|
||||
|
||||
## Self-host fallback when no hosted endpoint is available
|
||||
### 전월세 응답
|
||||
|
||||
2026-04-05 기준, upstream README/docs에는 고정 public MCP URL이 문서화돼 있지 않았다. 그래서 인터넷에서 공유 가능한 endpoint가 미리 준비돼 있지 않다고 보고 **self-host를 기본 운영 경로**로 잡는다.
|
||||
|
||||
### 1. Upstream Docker + Caddy로 로컬 HTTP 서버 띄우기
|
||||
|
||||
```bash
|
||||
git clone https://github.com/tae0y/real-estate-mcp.git
|
||||
cd real-estate-mcp
|
||||
cp .env.example .env
|
||||
printf 'DATA_GO_KR_API_KEY=your_api_key_here\n' >> .env
|
||||
REPOSITORY_ROOT=$(pwd)
|
||||
docker compose -f "$REPOSITORY_ROOT"/docker/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
헬스 체크는 upstream 문서의 MCP initialize 예시로 확인한다.
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}}}'
|
||||
```
|
||||
|
||||
### 2. Cloudflare Tunnel로 적합한 도메인 붙이기
|
||||
|
||||
```bash
|
||||
cloudflared tunnel login
|
||||
cloudflared tunnel create real-estate-mcp
|
||||
cloudflared tunnel route dns real-estate-mcp real-estate-mcp.example.com
|
||||
cat > ~/.cloudflared/config.yml <<'EOF'
|
||||
tunnel: real-estate-mcp
|
||||
credentials-file: /Users/YOUR_USER/.cloudflared/<tunnel-id>.json
|
||||
ingress:
|
||||
- hostname: real-estate-mcp.example.com
|
||||
service: http://localhost:80
|
||||
- service: http_status:404
|
||||
EOF
|
||||
cloudflared tunnel run real-estate-mcp
|
||||
```
|
||||
|
||||
공유용 HTTPS URL은 `https://real-estate-mcp.example.com/mcp` 형식으로 잡는다.
|
||||
public 인터넷에 노출한다면 upstream `docs/setup-oauth.md` 대로 `AUTH_MODE=oauth` 를 켜고 OAuth/Auth0를 붙인다.
|
||||
|
||||
### 3. macOS launchd 자동 실행
|
||||
|
||||
부팅 후 안정적으로 다시 뜨게 하려면 **launchd 는 Cloudflare Tunnel만 담당**하게 두고, upstream 서버 컨테이너는 Docker 쪽 재시작 정책에 맡긴다.
|
||||
`docker/docker-compose.yml` 에 이미 `restart: unless-stopped` 가 들어 있으므로, `docker compose ... up -d` 를 `RunAtLoad` + `KeepAlive` launchd job 으로 감싸면 오히려 즉시 종료된 프로세스를 launchd 가 반복 재실행하게 된다.
|
||||
|
||||
즉, 서버 쪽은 Docker Desktop/Engine 이 로그인 후 자동 기동되도록 설정한 다음 위의 `docker compose ... up -d --build` 를 한 번 실행해 두고, macOS launchd 에는 long-running 프로세스인 `cloudflared tunnel run ...` 만 등록한다.
|
||||
|
||||
`~/Library/LaunchAgents/com.kskill.real-estate-mcp.tunnel.plist`
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key><string>com.kskill.real-estate-mcp.tunnel</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/opt/homebrew/bin/cloudflared</string>
|
||||
<string>tunnel</string>
|
||||
<string>run</string>
|
||||
<string>real-estate-mcp</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>KeepAlive</key><true/>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
```bash
|
||||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.kskill.real-estate-mcp.tunnel.plist
|
||||
launchctl enable gui/$(id -u)/com.kskill.real-estate-mcp.tunnel
|
||||
```
|
||||
|
||||
위 예시는 macOS 기준이다. Linux/Windows에서는 Docker 서비스 자동 시작 + systemd/서비스 관리자로 tunnel 같은 long-running 프로세스를 따로 등록한다.
|
||||
매매와 동일 구조이나 아이템에 `deposit_10k`, `monthly_rent_10k`, `contract_type` 이 포함되고, summary에 `median_deposit_10k`, `monthly_rent_avg_10k` 등이 들어간다.
|
||||
|
||||
## Response policy
|
||||
|
||||
- 실거래가/전월세 요청이면 `get_region_code` 로 행정구역 코드를 먼저 확인한 뒤 자산 타입별 tool로 조회한다.
|
||||
- 아파트 매매는 `get_apartment_trades`, 아파트 전월세는 `get_apartment_rent` 를 우선 사용한다.
|
||||
- 오피스텔/빌라/단독주택/상업업무용은 자산 타입에 맞는 전용 tool로 라우팅한다.
|
||||
- 실거래가/전월세 요청이면 `region-code` endpoint로 행정구역 코드를 먼저 확인한 뒤 자산 타입별 endpoint로 조회한다.
|
||||
- 아파트 매매는 `apartment/trade`, 아파트 전월세는 `apartment/rent` 를 우선 사용한다.
|
||||
- 오피스텔/빌라/단독주택/상업업무용은 자산 타입에 맞는 endpoint로 라우팅한다.
|
||||
- 사용자가 동/건물명/연월을 덜 줬으면 지역, 단지명, 기준 월을 먼저 보강한다.
|
||||
- 실거래가와 호가를 섞어 말하지 않는다. 이 스킬은 국토교통부 기반 실거래/전월세 신고 데이터를 우선 다룬다.
|
||||
- 인터넷 공유용 endpoint가 미리 없다면 self-host + Cloudflare Tunnel + launchd 운영 경로를 안내한다.
|
||||
- upstream 소스는 이 저장소에 복사하지 않는다.
|
||||
- 실거래가와 호가를 섞어 말하지 않는다. 이 스킬은 국토교통부 기반 실거래/전월세 신고 데이터를 다룬다.
|
||||
|
||||
## Keep the answer compact
|
||||
|
||||
- 지역명 + 자산 타입 + 거래년월
|
||||
- 거래 건수 (summary.sample_count)
|
||||
- 가격 요약: 중위값, 최소, 최대
|
||||
- 상위 3-5건 대표 거래 (이름, 면적, 층, 가격, 날짜)
|
||||
- 전월세면 보증금 + 월세 요약도 포함
|
||||
|
||||
## Failure modes
|
||||
|
||||
- `lawd_cd` 또는 `deal_ymd` 형식이 잘못되면 400 응답
|
||||
- 프록시 서버에 `DATA_GO_KR_API_KEY` 가 없으면 503 응답
|
||||
- upstream MOLIT API 오류면 502 + `molit_api_XXX` 에러 코드
|
||||
- 해당 지역/기간에 데이터가 없으면 빈 `items` 배열 반환
|
||||
|
||||
## Done when
|
||||
|
||||
- 요청 자산 타입에 맞는 `real-estate-mcp` tool이 선택되었다.
|
||||
- 필요한 경우 `get_region_code` 로 지역코드를 먼저 확인했다.
|
||||
- 실거래가/전월세/청약/경매 중 적절한 결과를 조회했다.
|
||||
- 로컬 stdio/HTTP 경로면 `DATA_GO_KR_API_KEY` 준비 여부를 확인했다.
|
||||
- hosted endpoint가 없으면 self-host + Cloudflare Tunnel + launchd 운영 경로를 제시했다.
|
||||
- 원본 MCP 링크(`https://github.com/tae0y/real-estate-mcp/tree/main`)를 함께 남겼다.
|
||||
- 요청 자산 타입에 맞는 endpoint를 선택했다.
|
||||
- 필요한 경우 `region-code` 로 지역코드를 먼저 확인했다.
|
||||
- 실거래가/전월세 결과를 조회하고 요약했다.
|
||||
- 원본 데이터 출처(국토교통부 실거래가 신고)를 함께 남겼다.
|
||||
|
||||
## Notes
|
||||
|
||||
- upstream: `https://github.com/tae0y/real-estate-mcp/tree/main`
|
||||
- upstream Codex guide: `https://github.com/tae0y/real-estate-mcp/blob/main/docs/setup-codex-cli.md`
|
||||
- upstream Docker guide: `https://github.com/tae0y/real-estate-mcp/blob/main/docs/setup-docker.md`
|
||||
- upstream OAuth guide: `https://github.com/tae0y/real-estate-mcp/blob/main/docs/setup-oauth.md`
|
||||
- official data source: 공공데이터포털 (`https://www.data.go.kr`)
|
||||
- 이 저장소에는 별도 workspace/package를 추가하지 않고 스킬 문서만 유지한다.
|
||||
- 원본 참고: `https://github.com/tae0y/real-estate-mcp/tree/main`
|
||||
- 공식 데이터 출처: 공공데이터포털 (`https://www.data.go.kr`)
|
||||
- 가격 단위: `price_10k`, `deposit_10k` = 만원 단위 (예: 245000 = 24억 5천만원)
|
||||
- 취소된 거래는 서버에서 자동 필터링된다.
|
||||
|
|
|
|||
43
scripts/build-region-codes.js
Normal file
43
scripts/build-region-codes.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
#!/usr/bin/env node
|
||||
// One-time script: convert upstream region_codes.txt to a deduplicated
|
||||
// 5-digit LAWD_CD JSON lookup used by k-skill-proxy.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/build-region-codes.js <path-to-region_codes.txt>
|
||||
//
|
||||
// Output: packages/k-skill-proxy/src/region-codes.json
|
||||
|
||||
const { readFileSync, writeFileSync } = require("node:fs");
|
||||
const { resolve } = require("node:path");
|
||||
|
||||
const inputPath = process.argv[2];
|
||||
if (!inputPath) {
|
||||
console.error("Usage: node scripts/build-region-codes.js <region_codes.txt>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const raw = readFileSync(resolve(inputPath), "utf-8");
|
||||
const lines = raw.split("\n").slice(1); // skip header
|
||||
|
||||
const codes = new Map();
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.split("\t");
|
||||
if (parts.length < 3) continue;
|
||||
|
||||
const [fullCode, name, status] = parts;
|
||||
if (status.trim() !== "존재") continue;
|
||||
|
||||
const lawdCd = fullCode.slice(0, 5);
|
||||
if (codes.has(lawdCd)) continue; // keep first (gu/gun-level) occurrence
|
||||
codes.set(lawdCd, name.trim());
|
||||
}
|
||||
|
||||
const sorted = Object.fromEntries(
|
||||
[...codes.entries()].sort(([a], [b]) => a.localeCompare(b))
|
||||
);
|
||||
|
||||
const outPath = resolve(__dirname, "../packages/k-skill-proxy/src/region-codes.json");
|
||||
writeFileSync(outPath, JSON.stringify(sorted, null, 2) + "\n", "utf-8");
|
||||
|
||||
console.log(`Wrote ${Object.keys(sorted).length} entries to ${outPath}`);
|
||||
|
|
@ -1338,13 +1338,12 @@ test("joseon-sillok-search install payload includes the documented helper comman
|
|||
}
|
||||
});
|
||||
|
||||
test("repository docs advertise the real-estate-search skill and upstream self-host guidance", () => {
|
||||
test("repository docs advertise the real-estate-search skill and proxy-based approach", () => {
|
||||
const readme = read("README.md");
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
const setup = read(path.join("docs", "setup.md"));
|
||||
const security = read(path.join("docs", "security-and-secrets.md"));
|
||||
const setupSkill = read(path.join("k-skill-setup", "SKILL.md"));
|
||||
const examplesSecrets = read(path.join("examples", "secrets.env.example"));
|
||||
const featureDocPath = path.join(repoRoot, "docs", "features", "real-estate-search.md");
|
||||
const featureDoc = read(path.join("docs", "features", "real-estate-search.md"));
|
||||
const skillPath = path.join(repoRoot, "real-estate-search", "SKILL.md");
|
||||
|
|
@ -1362,35 +1361,23 @@ test("repository docs advertise the real-estate-search skill and upstream self-h
|
|||
|
||||
for (const doc of [skill, featureDoc]) {
|
||||
assert.match(doc, /https:\/\/github\.com\/tae0y\/real-estate-mcp\/tree\/main/);
|
||||
assert.match(doc, /DATA_GO_KR_API_KEY/);
|
||||
assert.match(doc, /get_apartment_trades/);
|
||||
assert.match(doc, /get_apartment_rent/);
|
||||
assert.match(doc, /get_region_code/);
|
||||
assert.match(doc, /Codex CLI|Claude Desktop/);
|
||||
assert.match(doc, /Cloudflare Tunnel/i);
|
||||
assert.match(doc, /launchd/i);
|
||||
assert.match(doc, /uv run/);
|
||||
assert.match(doc, /cloudflared tunnel/i);
|
||||
assert.match(doc, /k-skill-proxy\.nomadamas\.org/);
|
||||
assert.match(doc, /\/v1\/real-estate\//);
|
||||
assert.match(doc, /apartment\/trade|apartment\/rent/);
|
||||
assert.match(doc, /region-code/);
|
||||
assert.doesNotMatch(doc, /packages\/real-estate-search/);
|
||||
assert.doesNotMatch(doc, /python-packages\/real-estate-search/);
|
||||
}
|
||||
|
||||
for (const doc of [install]) {
|
||||
assert.match(doc, /https:\/\/github\.com\/tae0y\/real-estate-mcp\/tree\/main/);
|
||||
assert.match(doc, /DATA_GO_KR_API_KEY/);
|
||||
assert.match(doc, /Codex CLI/);
|
||||
assert.match(doc, /Cloudflare Tunnel/i);
|
||||
assert.match(doc, /launchd/i);
|
||||
assert.match(doc, /uv run/);
|
||||
assert.match(doc, /cloudflared tunnel/i);
|
||||
assert.match(doc, /k-skill-proxy\.nomadamas\.org|hosted proxy/);
|
||||
}
|
||||
|
||||
for (const doc of [setup, security, setupSkill]) {
|
||||
assert.match(doc, /DATA_GO_KR_API_KEY/);
|
||||
assert.match(doc, /real-estate-mcp/);
|
||||
}
|
||||
|
||||
assert.match(examplesSecrets, /^DATA_GO_KR_API_KEY=replace-me$/m);
|
||||
assert.match(sources, /real-estate-mcp: https:\/\/github\.com\/tae0y\/real-estate-mcp\/tree\/main/);
|
||||
assert.match(roadmap, /한국 부동산 실거래가 조회 스킬 출시/);
|
||||
assert.ok(
|
||||
|
|
@ -1400,36 +1387,19 @@ test("repository docs advertise the real-estate-search skill and upstream self-h
|
|||
assert.equal(fs.existsSync(path.join(repoRoot, "packages", "real-estate-search")), false);
|
||||
});
|
||||
|
||||
test("real-estate-search docs keep the upstream Onbid WIP caveat and avoid launchd daemonize loops", () => {
|
||||
test("real-estate-search skill uses proxy endpoints not MCP self-host", () => {
|
||||
const featureDoc = read(path.join("docs", "features", "real-estate-search.md"));
|
||||
const installDoc = read(path.join("docs", "install.md"));
|
||||
const skill = read(path.join("real-estate-search", "SKILL.md"));
|
||||
|
||||
for (const doc of [skill, featureDoc]) {
|
||||
assert.match(doc, /get_public_auction_items/);
|
||||
assert.match(doc, /get_public_auction_item_detail/);
|
||||
assert.match(doc, /WIP|작업 중|준비 중/);
|
||||
assert.match(doc, /k-skill-proxy\.nomadamas\.org\/v1\/real-estate/);
|
||||
assert.match(doc, /curl/);
|
||||
assert.doesNotMatch(doc, /uv run/);
|
||||
assert.doesNotMatch(doc, /codex mcp add/);
|
||||
assert.doesNotMatch(doc, /Cloudflare Tunnel/i);
|
||||
assert.doesNotMatch(doc, /launchd/i);
|
||||
assert.doesNotMatch(doc, /docker compose/i);
|
||||
}
|
||||
|
||||
const skillLaunchdSection = skill.match(/##\s+.*launchd[\s\S]*?(?=\n##\s+|\n#\s+|$)/i)?.[0];
|
||||
const featureLaunchdSection = featureDoc.match(/###+\s+.*launchd[\s\S]*?(?=\n##\s+|\n#\s+|$)/i)?.[0];
|
||||
|
||||
assert.ok(skillLaunchdSection, "expected skill launchd section");
|
||||
assert.ok(featureLaunchdSection, "expected feature guide launchd section");
|
||||
|
||||
for (const section of [skillLaunchdSection, featureLaunchdSection]) {
|
||||
assert.doesNotMatch(section, /com\.kskill\.real-estate-mcp\.server/);
|
||||
assert.doesNotMatch(section, /launchctl .*real-estate-mcp\.server/i);
|
||||
assert.match(section, /restart:\s*unless-stopped|Docker (Desktop|Engine).*재기동|Docker.*자동 재시작/i);
|
||||
assert.match(section, /cloudflared[\s\S]*tunnel[\s\S]*run[\s\S]*real-estate-mcp/i);
|
||||
}
|
||||
|
||||
assert.doesNotMatch(installDoc, /launchd\s*로\s*서버\/터널을?\s*자동 실행/i);
|
||||
assert.match(installDoc, /launchd[\s\S]*(Cloudflare Tunnel|터널).*(만|전용)/i);
|
||||
assert.match(
|
||||
installDoc,
|
||||
/restart:\s*unless-stopped|Docker (Desktop|Engine).*재기동|Docker.*자동 재시작/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("repository docs advertise the shipped korean-spell-check helper assets", () => {
|
||||
|
|
@ -1463,11 +1433,11 @@ test("repository docs advertise the cheap-gas-nearby skill and Opinet key requir
|
|||
assert.match(install, /--skill cheap-gas-nearby/);
|
||||
|
||||
for (const doc of [setup, security, setupSkill]) {
|
||||
assert.match(doc, /OPINET_API_KEY/);
|
||||
assert.match(doc, /오피넷|Opinet/);
|
||||
assert.match(doc, /주유소 가격|OPINET_API_KEY/);
|
||||
assert.match(doc, /hosted proxy|proxy.*경유/);
|
||||
}
|
||||
|
||||
assert.match(examplesSecrets, /^OPINET_API_KEY=replace-me$/m);
|
||||
assert.doesNotMatch(examplesSecrets, /^OPINET_API_KEY=replace-me$/m);
|
||||
assert.match(sources, /https:\/\/www\.opinet\.co\.kr\/user\/custapi\/openApiInfo\.do/);
|
||||
assert.match(sources, /https:\/\/www\.opinet\.co\.kr\/api\/aroundAll\.do/);
|
||||
assert.match(sources, /https:\/\/www\.opinet\.co\.kr\/api\/detailById\.do/);
|
||||
|
|
@ -1532,7 +1502,7 @@ test("repository docs advertise the han-river-water-level skill and rollout-pend
|
|||
}
|
||||
|
||||
assert.match(setup, /한강 수위 정보 조회 \| 사용자 시크릿 불필요/);
|
||||
assert.match(setup, /한강 수위는 .*KSKILL_PROXY_BASE_URL.*기본 hosted path/);
|
||||
assert.match(setup, /한강 수위.*기본 hosted p/i);
|
||||
assert.match(security, /KSKILL_PROXY_BASE_URL.*서울 지하철.*route가 실제 배포된 proxy URL/);
|
||||
assert.match(sources, /hrfco\.go\.kr\/web\/openapiPage\/reference\.do/);
|
||||
assert.match(sources, /api\.hrfco\.go\.kr/);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue