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:
Jeffrey (Dongkyu) Kim 2026-04-06 16:50:27 +09:00
commit e81388f0d5
18 changed files with 1784 additions and 376 deletions

View file

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

View file

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

View file

@ -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)를 본다.

View file

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

View file

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

View file

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

View file

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

View file

@ -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": {

View 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,
};

View 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": "전북특별자치도 부안군"
}

View 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 };

View file

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

View 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");
});

View 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("세종")));
});

View file

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

View file

@ -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천만원)
- 취소된 거래는 서버에서 자동 필터링된다.

View 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}`);

View file

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