feat: 개별공시지가(gongsijiga-search) 스킬 추가 (#200)

* chore: version packages

* Merge dev into main (#197)

* fix(toss-securities): clarify session expiry and quote 403 handling

* Clarify toss empty-output session expiry

Portfolio and watchlist reads can exit successfully with empty payloads when the stored Toss session has expired. The empty-output path now verifies the session before JSON parsing and only promotes confirmed invalid auth doctor data into TossSessionExpiredError.

Constraint: Scope is limited to toss-securities issue #126 follow-up on PR #192

Rejected: Treat auth doctor execution failures as expired sessions | unsupported or failing doctor output is inconclusive without parsed session.valid=false

Confidence: high

Scope-risk: narrow

Directive: Keep empty-result session expiry classification tied to explicit auth doctor confirmation

Tested: npm run test --workspace toss-securities; npm run lint --workspace toss-securities; npm run ci; manual mock tossctl blank stdout invalid/inconclusive doctor checks

* Avoid false session-expiry labels for validation errors

The toss wrapper now treats bare validation_error text as an upstream command failure instead of a session-expired signal. Structured auth doctor JSON remains the source of truth for empty portfolio/watchlist invalid-session promotion, while known stored-session-invalid stderr still maps to TossSessionExpiredError.\n\nConstraint: PR #192 follow-up must stay scoped to issue #126 toss-securities behavior.\nRejected: Keep validation_error in the global regex | it mislabels auth doctor transport failures and quote 403 validation errors as session expiry.\nConfidence: high\nScope-risk: narrow\nDirective: Do not broaden the free-text session classifier without regressions for auth doctor and quote upstream validation failures.\nTested: npm run lint --workspace toss-securities; npm run test --workspace toss-securities; npm run ci; manual mock tossctl validation_error checks; architect verification CLEAR\nNot-tested: Live tossctl network/auth session against real Toss upstream

* Align court auction lookup with monthly site search (#196)

The court auction notice page posts a YYYYMM search key from its 조회 button and returns a month of rows. Keep day inputs as a compatibility filter over the monthly response and normalize the current nested detail payload shape.

Constraint: courtauction.go.kr has no public API and blocks bursty automated calls.

Rejected: querying every day independently | the upstream search surface is month-based and day calls return false empty results.

Confidence: high

Scope-risk: narrow

Directive: Preserve the site-observed YYYYMM notice search contract unless the PGJ143M01 XHR changes again.

Tested: npm --workspace packages/court-auction-notice-search test; npm run ci; live 서울중앙지방법원 2026-05 notice/detail smoke lookup.

Not-tested: PR CI after push.

Co-authored-by: OmX <omx@oh-my-codex.dev>

* Guide crawler skills toward reusable discovery (#195)

* chore: version packages

* Guide crawler skills toward reusable discovery

Constraint: User requested insane-search-style guidance for future crawling k-skills without unrelated implementation changes.
Rejected: Adding crawler code or a standalone template | too broad for a docs guidance change and risks dependency creep.
Confidence: high
Scope-risk: narrow
Directive: Keep site-specific access details inside individual skills after a site-agnostic discovery pass.
Tested: npm run ci
Not-tested: Live crawler behavior; documentation-only change.

* Clarify crawler skill discovery guidance

Constraint: Crawling k-skills need site-dependent recipes, but should derive them through a reusable discovery pass.
Rejected: Leaving guidance only in docs/adding-a-skill.md | AGENTS.md and CLAUDE.md also guide future agents.
Confidence: high
Scope-risk: narrow
Directive: Use site-agnostic discovery to find, then explicitly package, the target site's stable access path.
Tested: npm run ci
Not-tested: Live crawler behavior; documentation-only change.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* Ground corporate registration guidance in official form sources

Keep the consulting skill focused on draft/checklist support while pointing users to current IROS and law.go.kr form sources for submission-ready artifacts.

Constraint: official registry forms can change outside the repository and must be re-downloaded at use time

Rejected: committing copied official HWP/HWPX/PDF forms | they would become stale and risk misleading users

Confidence: high

Scope-risk: narrow

Directive: do not treat Markdown templates as substitutes for official registry submission forms

Tested: npm test

* Ground incorporation drafting in real HWP forms

Bundle official court incorporation forms plus public startup incorporation attachments, and make rhwp-filled HWP outputs the default drafting path for the corporate-registration skill. Replace the listed-company articles reference with a startup-suitable Ministry of Justice stock-company form and record source manifests for bundled binaries.

Constraint: user requires actual sourced HWP templates, not generated placeholder binaries.
Rejected: markdown-only drafting | it cannot produce submission-shaped Korean registry forms.
Rejected: listed-company standard articles as the default reference | it is mismatched for typical startup incorporation.
Confidence: high
Scope-risk: moderate
Directive: keep bundled HWP forms source-backed, sanitized, and edited only through copied working files.
Tested: node --test scripts/skill-docs.test.js; npm run lint; k-skill-rhwp info on bundled HWP files; kordoc conversion spot checks.
Not-tested: manual opening every HWP in Hancom Office and live registry submission.
Co-authored-by: OmX <omx@oh-my-codex.dev>

* Streamline corporate registration forms workflow

Prioritize saved HWP forms for ordinary stock-company promoter incorporations, make required court-registry receipts and director identity certificates explicit, and remove the redundant markdown articles template so the skill stays HWP-first.

Constraint: 법원등기소 기준 체크리스트 must include fee receipts, director seal/signature certificates, and resident-record documents.

Rejected: Keeping a separate markdown articles template | duplicated the stored HWP articles workflow and encouraged non-HWP drafting.

Confidence: high

Scope-risk: narrow

Directive: Keep corporate-registration-consulting focused on stored HWP form copies and explicit issued-document checklists.

Tested: node --test --test-name-pattern 'corporate-registration-consulting' scripts/skill-docs.test.js; node --check scripts/skill-docs.test.js; ./scripts/validate-skills.sh; git diff --check

Not-tested: Full npm run ci was not run because this is a skill documentation/template refactor, not release or package automation.

---------

Co-authored-by: galvaomica <galvaomica@galvaomicaui-MacBookAir.local>
Co-authored-by: OmX <omx@oh-my-codex.dev>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* chore: version packages (#198)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* feat(realtyprice): add address parsing and sido code mapping

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(realtyprice): use string sido codes for consistency with upstream API

* feat(realtyprice): add response normalization and buildResponse

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(realtyprice): add upstream cascade fetch functions with timeout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(realtyprice): add lookupGongsijiga orchestrator with region matching

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(realtyprice): add simple in-memory cache with TTL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(proxy): register GET /realtyprice route with caching

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add gongsijiga-search SKILL.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: add changeset for gongsijiga-search

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(realtyprice): align with actual realtyprice.kr API response format

- Response wraps data in model.list (not bjdList/gsiList)
- Field names are code/name (not bjd_cd/bjd_nm)
- bun2 empty → send "0000" (not empty string)
- eupmyeondong matching: try full string match first (API returns
  combined "면 리" names like "청계면 청천리")

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(gongsijiga-search): align /realtyprice route with v1 API convention

- Change route from /realtyprice to /v1/realtyprice for consistency with other proxy endpoints.
- Add realtypriceConfigured flag to /health upstreams.
- Normalize address cache key by collapsing multiple whitespaces.
- Update SKILL.md and README.md to reflect the new v1 path.

* feat(gongsijiga-search): add Sejong special-city support

- parseAddress: allow 3-token minimum for Sejong (no sigungu) and set sigungu to empty string.
- lookupGongsijiga: skip sigungu lookup for Sejong (sidoCode 29), use fixed sggCode 36110.
- Add Sejong parseAddress and lookupGongsijiga test cases.
- Update SKILL.md with Sejong address format examples.

* refactor(gongsijiga-search): split realtyprice.kr lookup into standalone package

realtyprice.kr is a fully public endpoint that needs no API key, so per
the new k-skill-proxy inclusion rule (proxy is for keyed upstreams only)
the helper now ships as `gongsijiga-search` and is invoked directly from
the user's machine.

- new workspace package packages/gongsijiga-search/ following the
  blue-ribbon-nearby/coupang-product-search convention (publishConfig,
  files, repository, keywords)
- remove /v1/realtyprice route, realtyprice.js, realtyprice.test.js, and
  the realtypriceConfigured health flag from k-skill-proxy
- document the inclusion rule in AGENTS.md and CLAUDE.md so future skills
  default to direct calls when no key is required
- advertise the new skill in README.md, docs/install.md, and add
  docs/features/gongsijiga-search.md
- drop the hardcoded toss-securities lockfile version assertion that
  pinned a workspace version (would block changesets version-packages)
  and document the anti-pattern in AGENTS.md / CLAUDE.md
- changesets: refresh the proxy refactor message and add a patch
  changeset so the new gongsijiga-search package gets published

---------

Co-authored-by: Jeffrey (Dongkyu) Kim <vkehfdl1@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: galvaomica <galvaomica@galvaomicaui-MacBookAir.local>
Co-authored-by: OmX <omx@oh-my-codex.dev>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Inho Jeong 2026-05-05 00:27:31 +09:00 committed by GitHub
commit 2ff51db5d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 2017 additions and 8 deletions

View file

@ -0,0 +1,7 @@
---
"gongsijiga-search": patch
---
feat: extract realtyprice.kr lookup from k-skill-proxy into a standalone `gongsijiga-search` workspace package
The previous `/v1/realtyprice` proxy route called a fully public endpoint (realtyprice.kr) that needs no API key, so per the new k-skill-proxy inclusion rule (proxy is for keyed upstreams only) the helper now ships as its own package and is invoked directly from the user's machine.

View file

@ -0,0 +1,5 @@
---
"k-skill-proxy": patch
---
refactor: remove realtyprice route (moved to standalone gongsijiga-search package)

View file

@ -20,6 +20,7 @@ These rules are repo-specific and apply to everything under this directory.
## Testing anti-patterns
- **Never write tests that assert `.changeset/*.md` files exist.** Changesets are consumed (deleted) by `changeset version` during the release flow. Any test guarding changeset file presence will break CI on the version-bump commit and block the release pipeline.
- **Never write tests that pin a workspace package's `version` field** (in `package.json` or `package-lock.json`). `changeset version` bumps these on every release, so any hardcoded version assertion will fail the next release commit and block the npm publish pipeline. Stable invariants like `name`, `license`, `engines.node`, or workspace link metadata are fine to assert; the `version` is not.
## Development skill install rules
@ -38,6 +39,7 @@ These rules are repo-specific and apply to everything under this directory.
## Free API proxy policy
- The built-in `k-skill-proxy` is for **free APIs only**.
- **k-skill-proxy inclusion rule**: A skill should be served through `k-skill-proxy` **only when the upstream requires an API key** (e.g., data.go.kr, KRX, Naver Search Open API, NEIS, Data4Library). Fully public endpoints that work without any authentication (e.g., realtyprice.kr) should be called directly from the user's machine, not routed through the proxy.
- Default posture: public read-only endpoint, **no proxy auth by default**.
- Keep free-API proxy surfaces narrow, allowlisted, cache-backed, and rate-limited.
- If abuse or operational issues appear later, add stricter controls then instead of preemptively requiring auth.

View file

@ -3,6 +3,7 @@
## Testing anti-patterns
- **Never write tests that assert `.changeset/*.md` files exist.** Changesets are consumed (deleted) by `changeset version` during the release flow. Any test guarding changeset file presence will break CI on the version-bump commit and block the release pipeline.
- **Never write tests that pin a workspace package's `version` field** (in `package.json` or `package-lock.json`). `changeset version` bumps these on every release, so any hardcoded version assertion will fail the next release commit and block the npm publish pipeline. Stable invariants like `name`, `license`, `engines.node`, or workspace link metadata are fine to assert; the `version` is not.
## Crawling/search skill authoring
@ -18,3 +19,4 @@
- **cron job** 이 매시 정각에 `origin/main` fetch → fast-forward pull → pm2 restart 실행
- 따라서 proxy route 변경은 **main에 merge되어야 프로덕션에 반영**된다. dev에서 코드를 바꿔도 프로덕션 proxy에는 영향 없음.
- 로컬 테스트는 `node packages/k-skill-proxy/src/server.js` 로 직접 실행하거나 `node --test packages/k-skill-proxy/test/server.test.js` 로 확인.
- **Proxy 편입 규칙**: k-skill-proxy에 route를 추가하려면 upstream이 API 키를 필요로 해야 한다. 공개 엔드포인트(키 불필요)는 skill 코드에서 직접 호출하고 프록시를 거치지 않는다.

View file

@ -37,6 +37,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 한국 사업자 장부 자동화 | `korean-jangbu-for` | `kimlawtech/korean-jangbu-for` 기반 카드·은행·영수증·세금계산서 입력 → 표준 거래내역·계정과목·세무사 전달 CSV·경영 리포트 생성 thin wrapper | 선택사항(CODEF BYOK 자동 수집 시 필요) | [한국 사업자 장부 자동화 가이드](docs/features/korean-jangbu-for.md) |
| 한국 개인정보처리방침·이용약관 자동 생성 | `korean-privacy-terms` | Next.js 프로젝트에 개인정보보호법·약관규제법·전자상거래법 기반 개인정보처리방침/이용약관/쿠키 배너/동의 모달을 생성하는 `kimlawtech/korean-privacy-terms` (Apache-2.0) thin wrapper | 불필요 | [한국 개인정보처리방침·이용약관 자동 생성 가이드](docs/features/korean-privacy-terms.md) |
| 한국 부동산 실거래가 조회 | `real-estate-search` | 아파트/오피스텔/빌라/단독주택 실거래가·전월세·지역코드 조회 | 불필요 | [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md) |
| 개별공시지가 조회 | `gongsijiga-search` | realtyprice.kr 공개 API에서 지번 단위 개별공시지가(원/㎡) 다년도 추이·전년 대비 변동률 조회 | 불필요 | [개별공시지가 조회 가이드](docs/features/gongsijiga-search.md) |
| LH 청약 공고문 조회 | `lh-notice-search` | 한국토지주택공사(LH) 임대/분양/주거복지(신혼희망타운)/토지/상가 공고를 지역·상태·공고유형·키워드로 조회하고 마감 여부를 KST 기준으로 표시 | 불필요 | [LH 청약 공고문 조회 가이드](docs/features/lh-notice-search.md) |
| 법원 경매 부동산 매각공고 조회 | `court-auction-notice-search` | 대법원경매정보(courtauction.go.kr) 부동산 매각공고를 매각기일·법원·기일/기간 입찰 조건으로 검색해 사건번호·용도·주소·감정평가액·최저매각가격을 펼치고, 사건번호로 직접 사건정보·물건내역·매각기일이력을 조회 | 불필요 | [법원 경매 부동산 매각공고 조회 가이드](docs/features/court-auction-notice-search.md) |
| 장학금 검색 및 조회 | `korean-scholarship-search` | 한국장학재단·전국 대학교·재단·기업 장학 공고를 검색해 금액·자격·지원구간·링크를 정리하고 KST 기준 현재 날짜 마감 상태와 조건별 필터링까지 제공 | 불필요 | [장학금 검색 및 조회 가이드](docs/features/korean-scholarship-search.md) |
@ -127,6 +128,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [한국 개인정보처리방침·이용약관 자동 생성 가이드](docs/features/korean-privacy-terms.md)
- [한국 사업자 장부 자동화 가이드](docs/features/korean-jangbu-for.md)
- [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md)
- [개별공시지가 조회 가이드](docs/features/gongsijiga-search.md)
- [LH 청약 공고문 조회 가이드](docs/features/lh-notice-search.md)
- [법원 경매 부동산 매각공고 조회 가이드](docs/features/court-auction-notice-search.md)
- [장학금 검색 및 조회 가이드](docs/features/korean-scholarship-search.md)

View file

@ -0,0 +1,93 @@
# 개별공시지가 조회 가이드
## 이 기능으로 할 수 있는 일
- 한국 국토교통부 부동산공시가격알리미(`realtyprice.kr`)에서 지번 단위 **개별공시지가**(원/㎡) 조회
- 다년도 추이(과거 수년치)와 전년 대비 변동률 정규화 JSON 출력
- 17개 광역자치단체(서울, 세종특별자치시 포함) 모든 시·군·구 지원
- 산 지번 / 본번-부번 모두 지원
## 가장 중요한 규칙
`realtyprice.kr`는 **API 키가 필요 없는 완전 공개 엔드포인트**이므로 이 스킬은 `k-skill-proxy`를 경유하지 않는다. 사용자 머신에서 직접 upstream을 호출한다. (저장소의 *k-skill-proxy inclusion rule* — 프록시는 API 키가 필요한 upstream만 다룬다.)
## 무엇을 가져오나
- 공시지가는 매년 1월 1일 기준, 4~5월에 공시된다.
- 재산세, 종합부동산세, 양도소득세 등 **세금 산정의 법적 기준 단가**다.
- 공시지가 ≠ 시세. 시세는 통상 공시지가의 1.5~3배.
> 시세, 실거래가, 매매가, 호가가 필요하면 [`real-estate-search`](real-estate-search.md) 또는 다른 스킬을 사용한다.
## 먼저 필요한 것
없음. 인터넷 연결과 Node.js 18+ 만 있으면 된다.
## 사용 방법
### 설치
```bash
npm install gongsijiga-search
```
### 기본 호출
```js
const { lookupGongsijiga } = require("gongsijiga-search");
const result = await lookupGongsijiga("서울특별시 강남구 역삼동 736");
console.log(result.latest.price_per_sqm); // 72340000
console.log(result.yoy_change_pct); // 5.45
```
### 입력 주소 형식
`<시도> <시군구> <읍면동…> [산] <본번[-부번]>`
| 형식 | 예시 |
| --- | --- |
| 일반 | `서울특별시 강남구 역삼동 736` |
| 약칭 시도 | `서울 강남구 역삼동 736` |
| 부번 있음 | `경기 성남시 분당구 정자동 178-3` |
| 산 지번 | `서울 서초구 서초동 산 1-2` |
| 다토큰 읍면동 | `전남 무안군 청계면 청천리 100-5` |
| 세종 (시군구 없음) | `세종 어진동 575` 또는 `세종특별자치시 어진동 575` |
### 응답 모양
```json
{
"address": "서울특별시 강남구 역삼동 736",
"jibun": "736번지",
"san": false,
"latest": {
"year": 2026,
"price_per_sqm": 72340000,
"notice_date": "2026-04-30",
"base_date": "2026-01-01"
},
"history": [
{ "year": 2026, "price_per_sqm": 72340000, "notice_date": "2026-04-30" },
{ "year": 2025, "price_per_sqm": 68600000, "notice_date": "2025-04-30" }
],
"yoy_change_pct": 5.45,
"source_url": "https://www.realtyprice.kr/notice/gsindividual/search.htm"
}
```
## 실패 모드
| `error.code` | 의미 | 처리 |
| --- | --- | --- |
| `ADDRESS_PARSE_FAILED` | 주소 파싱 실패 / 미인식 시도 | "행정구역 + 본번이 포함된 주소가 필요합니다" 안내 후 재요청 |
| `INVALID_BUNJI` | 본번 비숫자 또는 4자리 초과 | 본번 형식 재요청 |
| `REGION_NOT_FOUND` | 시군구/읍면동 매칭 실패 | `err.candidates` 후보(최대 3개) 제안 |
| `LAND_NOT_FOUND` | 해당 지번 미등재 | "본번/부번 오타이거나 도로/하천 등 미과세 토지" 설명 |
| `UPSTREAM_ERROR` | `realtyprice.kr` 비정상 응답 | "데이터 출처 일시 장애. 잠시 후 재시도" + `source_url` |
| `UPSTREAM_TIMEOUT` | 30초 타임아웃 | UPSTREAM_ERROR와 동일 처리 |
## 출처
- [부동산공시가격알리미](https://www.realtyprice.kr/notice/gsindividual/search.htm) — 국토교통부
- 패키지 소스: [`packages/gongsijiga-search/`](../../packages/gongsijiga-search)

View file

@ -277,7 +277,7 @@ npm run ci
### Node 패키지
```bash
npm install -g kordoc pdfjs-dist kbo-game kbl-results kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby public-restroom-nearby korean-law-mcp market-kurly-search daiso bunjang-cli court-auction-notice-search
npm install -g kordoc pdfjs-dist kbo-game kbl-results kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby public-restroom-nearby korean-law-mcp market-kurly-search daiso bunjang-cli court-auction-notice-search gongsijiga-search
export NODE_PATH="$(npm root -g)"
```

145
gongsijiga-search/SKILL.md Normal file
View file

@ -0,0 +1,145 @@
---
name: gongsijiga-search
description: |
대한민국 국토교통부가 매년 공시하는 "개별공시지가"(원/㎡) 조회.
지번 단위 토지의 정부 공시 단가로, 재산세·종부세·양도세 등
세금 산정의 법적 기준이다. **시세/실거래가가 아니다.**
Use when the user asks for 공시지가, 개별공시지가, 토지 공시단가,
세무 계산용 토지 단가, or "이 땅 공시지가 얼마야".
Do NOT use for 시세, 실거래가, 매매가, 호가, 공동주택가격
(those need a different data source).
license: MIT
metadata:
category: real-estate
locale: ko-KR
phase: v1
---
# 개별공시지가 조회
## What this skill does
한국 국토교통부 부동산공시가격알리미(realtyprice.kr)에서 특정 필지의 **개별공시지가**(원/㎡)를 조회한다. 다년도 추이(최근 5년 이내)와 전년 대비 변동률을 정규화된 JSON으로 반환한다.
공시지가는 매년 1월 1일 기준, 4~5월 공시. 세금(재산세, 종합부동산세, 양도소득세) 산정의 법적 기준 단가다.
## When to use
- "서울 강남구 역삼동 736 공시지가 알려줘"
- "전라남도 무안군 청계면 청천리 100번지 개별공시지가"
- "서초동 산 1-2 공시지가 추이"
- 세무 계산에서 토지 공시 단가가 필요할 때
## When NOT to use
- 시세, 실거래가, 매매가, 호가 → 다른 데이터 소스 필요
- 공동주택가격, 표준지공시지가, 단독주택가격 → 별도 스킬
- 토지이용계획 → eum.go.kr 별도 스킬
## Prerequisites
- 인터넷 연결
- `curl` (또는 HTTP 호출 도구)
사용자에게 필요한 시크릿 없음 (공개 데이터).
## Default path
`gongsijiga-search` npm 패키지를 직접 호출한다. realtyprice.kr는 API 키가 필요 없는 공개 엔드포인트이므로 `k-skill-proxy`를 경유하지 않는다.
설치:
```bash
npm install gongsijiga-search
```
호출:
```bash
node -e "
const { lookupGongsijiga } = require('gongsijiga-search');
lookupGongsijiga('서울 강남구 역삼동 736').then(console.log).catch(console.error);
"
```
## Workflow
### 1. 사용자 입력 수집
사용자에게 **시도 + 시군구 + 읍면동 + 지번**이 포함된 주소를 요청한다.
- 최소 필수: 시도, 시군구, 읍면동, 본번
- **세종특별자치시**는 시군구가 없으므로 "세종 [읍면동] [지번]" 형식
- 산 지번이면 "산" 키워드 포함
- 부번이 있으면 "100-5" 형식
예시: "서울 강남구 역삼동 736", "전남 무안군 청계면 청천리 산 1-2", "세종 고용동 100"
시도가 누락된 주소(예: "역삼동 736")는 조회 불가 — 시도를 물어본다.
### 2. 직접 호출
`gongsijiga-search` 모듈을 사용해 realtyprice.kr를 직접 호출한다 (API 키 불필요, 프록시 경유 안 함):
```javascript
const { lookupGongsijiga } = require('gongsijiga-search');
const result = await lookupGongsijiga('서울 강남구 역삼동 736');
```
### 3. 응답 해석 및 출력
성공 응답 예시:
```json
{
"address": "서울 강남구 역삼동 736",
"jibun": "736번지",
"san": false,
"latest": {
"year": 2026,
"price_per_sqm": 72340000,
"notice_date": "2026-04-30",
"base_date": "2026-01-01"
},
"history": [...],
"yoy_change_pct": 5.45,
"source_url": "https://www.realtyprice.kr/notice/gsindividual/search.htm"
}
```
**출력 규칙:**
1. 반드시 "공시지가" 단어 사용. "가격/시세/매매가" 단어 금지.
2. 헤더: `[정부 공시] 개별공시지가 — {address}`
3. 최신값: `{year}년 공시지가: {price_per_sqm:,}원/㎡ (전년 대비 +{yoy_change_pct}%)`
4. 추이 표 (history 배열을 연도순 테이블로):
| 연도 | 공시지가 (원/㎡) | 공시일 |
|------|-----------------|--------|
| 2026 | 72,340,000 | 2026-04-30 |
| ... | ... | ... |
5. 마지막 줄 disclaimer:
`본 단가는 세금 산정용 정부 공시 가격으로, 시세나 실거래가와 다릅니다.`
### 4. 올해 미발표 안내
`latest.year`가 올해보다 작으면: "올해 공시지가는 아직 미발표 상태입니다. 최신 데이터는 {latest.year}년 기준입니다." 안내.
## Failure modes
| error.code | 의미 | 행동 |
|---|---|---|
| `ADDRESS_PARSE_FAILED` | 주소 파싱 실패 | "행정구역 + 본번까지 포함된 주소가 필요합니다" + 예시 |
| `INVALID_BUNJI` | 본번 형식 오류 | 본번 입력 형식 재요청 |
| `REGION_NOT_FOUND` | 행정구역 매칭 실패 | candidates 배열이 있으면 제안, 없으면 오타 확인 요청 |
| `LAND_NOT_FOUND` | 해당 지번 미등재 | "본번/부번 오타이거나 도로/하천 등 미과세 토지" 설명 |
| `UPSTREAM_ERROR` | realtyprice.kr 장애 | "데이터 출처 일시 장애. 잠시 후 재시도" + source_url |
| `UPSTREAM_TIMEOUT` | 30초 초과 | UPSTREAM_ERROR와 동일 |
## Notes
- 공시지가 ≠ 시세. 시세는 통상 공시지가의 1.5~3배.
- 매년 1월 1일 기준, 4~5월 발표. 1~4월은 전년도가 최신.
- realtyprice.kr는 API 키 불필요 (공개 데이터).

23
package-lock.json generated
View file

@ -867,6 +867,10 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gongsijiga-search": {
"resolved": "packages/gongsijiga-search",
"link": true
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"dev": true,
@ -1730,7 +1734,7 @@
}
},
"packages/court-auction-notice-search": {
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"bin": {
"court-auction-notice-search": "bin/court-auction-notice-search.js"
@ -1750,6 +1754,13 @@
"node": ">=18"
}
},
"packages/gongsijiga-search": {
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/hipass-receipt": {
"version": "0.3.0",
"license": "MIT",
@ -1771,7 +1782,7 @@
}
},
"packages/k-skill-proxy": {
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"dependencies": {
"fastify": "^5.3.3"
@ -1781,7 +1792,7 @@
}
},
"packages/k-skill-rhwp": {
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"dependencies": {
"@rhwp/core": "^0.7.3"
@ -1829,21 +1840,21 @@
}
},
"packages/parking-lot-search": {
"version": "0.1.2",
"version": "0.1.3",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/public-restroom-nearby": {
"version": "0.2.0",
"version": "0.3.0",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/toss-securities": {
"version": "0.2.0",
"version": "0.3.0",
"license": "MIT",
"engines": {
"node": ">=18"

View file

@ -0,0 +1,100 @@
# gongsijiga-search
대한민국 국토교통부 부동산공시가격알리미(`realtyprice.kr`)의 공개 API를 호출해 지번 단위 **개별공시지가**(원/㎡)를 조회하는 Node.js 패키지입니다. 다년도 추이와 전년 대비 변동률을 정규화된 JSON으로 돌려줍니다.
> [!NOTE]
> `realtyprice.kr`는 API 키가 필요 없는 완전 공개 엔드포인트이므로 이 패키지는 `k-skill-proxy`를 경유하지 않고 사용자 머신에서 직접 upstream을 호출합니다. (참고: 저장소의 *k-skill-proxy inclusion rule* — 프록시는 API 키가 필요한 upstream만 다룹니다.)
## 설치
배포 후:
```bash
npm install gongsijiga-search
```
이 저장소에서 개발할 때:
```bash
npm install
```
## 사용 예시
```js
const { lookupGongsijiga } = require("gongsijiga-search");
async function main() {
const result = await lookupGongsijiga("서울특별시 강남구 역삼동 736");
console.log(result.latest); // { year, price_per_sqm, notice_date, base_date }
console.log(result.history); // [{ year, price_per_sqm, notice_date }, ...] (descending)
console.log(result.yoy_change_pct); // 전년 대비 % (소수점 둘째 자리 반올림)
}
main().catch((err) => {
console.error(err.code, err.message);
process.exitCode = 1;
});
```
## 입력 주소 형식
`<시도> <시군구> <읍면동…> [산] <본번[-부번]>` 형태의 한국어 지번 주소.
- 시도: 17개 광역자치단체 풀네임/약칭 모두 지원 (예: `서울특별시` / `서울`)
- **세종특별자치시**는 시군구가 없으므로 `세종 <읍면동> <지번>` 형식
- 산 지번은 `산 1-2` 또는 `산1-2`
- 본번은 4자리 이하 숫자, 부번은 `-` 뒤에 옴
예: `서울 강남구 역삼동 736`, `전남 무안군 청계면 청천리 산 1-2`, `세종 어진동 575`.
## 응답 모양
```json
{
"address": "서울특별시 강남구 역삼동 736",
"jibun": "736번지",
"san": false,
"latest": {
"year": 2026,
"price_per_sqm": 72340000,
"notice_date": "2026-04-30",
"base_date": "2026-01-01"
},
"history": [
{ "year": 2026, "price_per_sqm": 72340000, "notice_date": "2026-04-30" },
{ "year": 2025, "price_per_sqm": 68600000, "notice_date": "2025-04-30" }
],
"yoy_change_pct": 5.45,
"source_url": "https://www.realtyprice.kr/notice/gsindividual/search.htm"
}
```
## 에러 코드
| `error.code` | 의미 | `statusCode` |
| --- | --- | --- |
| `ADDRESS_PARSE_FAILED` | 주소 파싱 실패 / 미인식 시도 / 토큰 부족 | 400 |
| `INVALID_BUNJI` | 본번이 비숫자 또는 4자리 초과 | 400 |
| `REGION_NOT_FOUND` | 시군구/읍면동 매칭 실패 (`err.candidates` 후보 최대 3개) | 404 |
| `LAND_NOT_FOUND` | 해당 지번이 공시지가에 등재되지 않음 | 404 |
| `UPSTREAM_ERROR` | realtyprice.kr 비정상 HTTP 응답 | 502 |
| `UPSTREAM_TIMEOUT` | 30초 타임아웃 | 504 |
## 공개 API
- `lookupGongsijiga(addressRaw, fetchFn?)` — 주소 → 정규화된 응답
- `parseAddress(rawAddress)` — 주소 파서 (지번/산/세종 처리)
- `parseSido(text)` — 시도명 → 2자리 코드
- `normalizeSearchResult(raw)` — gsiList 항목 → `{ year, price_per_sqm, notice_date }`
- `buildResponse({ address, jibun, san, history })` — 최종 응답 합성
- `fetchSigunguList`, `fetchEupmyeondongList`, `fetchGsiSearchList` — 단계별 upstream 호출
- `fetchWithTimeout(url, opts, timeoutMs?, fetchFn?)` — AbortController 기반 타임아웃
- `createCache()` — 단순 in-memory TTL 캐시 (Map 백엔드)
- `SIDO_MAP`, `REALTYPRICE_BASE_URL`, `REFERER`, `makeError`
## Notes
- 공시지가 ≠ 시세. 시세는 통상 공시지가의 1.5~3배.
- 매년 1월 1일 기준, 4~5월 발표. 1~4월에는 전년도가 최신.
- `realtyprice.kr` 호출에는 별도 `Referer` 헤더가 필요하며, 이 패키지가 자동 처리합니다.

View file

@ -0,0 +1,33 @@
{
"name": "gongsijiga-search",
"version": "0.1.0",
"description": "Client-side query helpers for Korean individual official land prices (개별공시지가) from realtyprice.kr",
"license": "MIT",
"main": "src/index.js",
"files": [
"src",
"README.md"
],
"engines": {
"node": ">=18"
},
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/NomaDamas/k-skill.git"
},
"keywords": [
"k-skill",
"korea",
"realtyprice",
"gongsijiga",
"land-price",
"real-estate"
],
"scripts": {
"lint": "node --check src/index.js && node --check src/realtyprice.js && node --check test/realtyprice.test.js",
"test": "node --test"
}
}

View file

@ -0,0 +1,41 @@
// gongsijiga-search — client-side query helpers for Korean individual official
// land prices (개별공시지가).
//
// Upstream: https://www.realtyprice.kr (public, no API key required)
//
// This module can be used directly from a user's machine without going through
// k-skill-proxy, because realtyprice.kr is a fully open public endpoint.
const {
REALTYPRICE_BASE_URL,
REFERER,
SIDO_MAP,
makeError,
parseSido,
parseAddress,
normalizeSearchResult,
buildResponse,
fetchWithTimeout,
fetchSigunguList,
fetchEupmyeondongList,
fetchGsiSearchList,
lookupGongsijiga,
createCache,
} = require("./realtyprice");
module.exports = {
REALTYPRICE_BASE_URL,
REFERER,
SIDO_MAP,
makeError,
parseSido,
parseAddress,
normalizeSearchResult,
buildResponse,
fetchWithTimeout,
fetchSigunguList,
fetchEupmyeondongList,
fetchGsiSearchList,
lookupGongsijiga,
createCache,
};

View file

@ -0,0 +1,585 @@
// realtyprice.js — helpers for querying realtyprice.kr 공시지가 (officially published
// land/building prices). This module provides pure parsing utilities and fetch
// helpers; the Fastify route is wired in server.js.
//
// Upstream entry point:
// https://www.realtyprice.kr/notice/gsindividual/search.htm (HTML form)
// The actual data comes from AJAX calls described in SKILL.md.
const REALTYPRICE_BASE_URL = "https://www.realtyprice.kr/notice";
const REFERER =
"https://www.realtyprice.kr/notice/gsindividual/search.htm";
// ---------------------------------------------------------------------------
// 시도 코드 매핑 (17개 시도)
// ---------------------------------------------------------------------------
// Maps every recognized name variant to its 2-digit code.
const SIDO_MAP = {
// 서울
"서울특별시": "11",
"서울": "11",
// 부산
"부산광역시": "21",
"부산": "21",
// 대구
"대구광역시": "22",
"대구": "22",
// 인천
"인천광역시": "23",
"인천": "23",
// 광주
"광주광역시": "24",
"광주": "24",
// 대전
"대전광역시": "25",
"대전": "25",
// 울산
"울산광역시": "26",
"울산": "26",
// 세종
"세종특별자치시": "29",
"세종": "29",
// 경기
"경기도": "41",
"경기": "41",
// 강원
"강원특별자치도": "42",
"강원도": "42",
"강원": "42",
// 충북
"충청북도": "43",
"충북": "43",
// 충남
"충청남도": "44",
"충남": "44",
// 전북
"전북특별자치도": "45",
"전라북도": "45",
"전북": "45",
// 전남
"전라남도": "46",
"전남": "46",
// 경북
"경상북도": "47",
"경북": "47",
// 경남
"경상남도": "48",
"경남": "48",
// 제주
"제주특별자치도": "50",
"제주도": "50",
"제주": "50",
};
// ---------------------------------------------------------------------------
// makeError
// ---------------------------------------------------------------------------
/**
* Creates an Error with additional `code` and `statusCode` properties.
* @param {string} code Machine-readable error code.
* @param {string} message Human-readable message.
* @param {number} statusCode HTTP status code to return to the client.
*/
function makeError(code, message, statusCode) {
const err = new Error(message);
err.code = code;
err.statusCode = statusCode;
return err;
}
// ---------------------------------------------------------------------------
// parseSido
// ---------------------------------------------------------------------------
/**
* Maps a Korean 시도 name (full or abbreviated) to its 2-digit string code.
* Returns null if the name is not recognized.
* @param {string} text
* @returns {string|null}
*/
function parseSido(text) {
if (!text) return null;
const code = SIDO_MAP[text.trim()];
return code !== undefined ? code : null;
}
// ---------------------------------------------------------------------------
// parseAddress
// ---------------------------------------------------------------------------
/**
* Parses a Korean land address string into its structural components.
*
* Expected shape (space-separated tokens):
* <시도> <시군구> [<읍면동> ] ["산"] <번지[-부번]>
*
* Returns:
* { sido, sidoCode, sigungu, eupmyeondong, san, bun1, bun2 }
*
* Throws:
* - code "ADDRESS_PARSE_FAILED" / statusCode 400
* when the first token is not a recognised 시도, or there are fewer
* than 4 tokens (시도 + 시군구 + 읍면동 + 번지 minimum).
* - code "INVALID_BUNJI" / statusCode 400
* when bun1 is non-numeric or longer than 4 digits.
*
* @param {string} rawAddress
* @returns {{ sido: string, sidoCode: string, sigungu: string,
* eupmyeondong: string, san: boolean, bun1: string, bun2: string }}
*/
function parseAddress(rawAddress) {
const tokens = (rawAddress || "").trim().split(/\s+/).filter(Boolean);
// First token must be a valid 시도 (needed to know if this is Sejong)
const sido = tokens[0];
const sidoCode = parseSido(sido);
if (sidoCode === null) {
throw makeError(
"ADDRESS_PARSE_FAILED",
`인식할 수 없는 시도입니다: "${sido}"`,
400
);
}
// 세종특별자치시 has no 시군구, so minimum is sido + eupmyeondong + bunji = 3 tokens
const isSejong = sidoCode === "29";
const minTokens = isSejong ? 3 : 4;
if (tokens.length < minTokens) {
throw makeError(
"ADDRESS_PARSE_FAILED",
`주소를 파싱할 수 없습니다: "${rawAddress}"`,
400
);
}
// Second token is 시군구 (empty for 세종)
const sigungu = isSejong ? "" : tokens[1];
// Remaining tokens (after sido (+ sigungu)): [...eupmyeondong tokens, (산?), bunji]
const rest = isSejong ? tokens.slice(1) : tokens.slice(2);
// The last token is always the bunji (번지) token (possibly with 산 prefix).
// If the second-to-last token is the standalone "산", it belongs to the
// bunji group rather than eupmyeondong.
let bunjiRaw;
let san = false;
let eupmyeondongTokens;
const lastToken = rest[rest.length - 1];
const secondLast = rest.length >= 2 ? rest[rest.length - 2] : null;
if (secondLast === "산") {
// e.g. ["서초동", "산", "1-2"] → eupmyeondong="서초동", san=true, bunji="1-2"
san = true;
bunjiRaw = lastToken;
eupmyeondongTokens = rest.slice(0, rest.length - 2);
} else if (/^산\d/.test(lastToken)) {
// e.g. ["서초동", "산1-2"] → eupmyeondong="서초동", san=true, bunji="1-2"
san = true;
bunjiRaw = lastToken.slice(1); // strip leading "산"
eupmyeondongTokens = rest.slice(0, rest.length - 1);
} else {
bunjiRaw = lastToken;
eupmyeondongTokens = rest.slice(0, rest.length - 1);
}
if (eupmyeondongTokens.length === 0) {
throw makeError(
"ADDRESS_PARSE_FAILED",
`읍면동을 파싱할 수 없습니다: "${rawAddress}"`,
400
);
}
const eupmyeondong = eupmyeondongTokens.join(" ");
// Strip trailing "번지"
const bunjiStripped = bunjiRaw.replace(/번지$/, "");
// Split bun1 / bun2 on "-"
const dashIdx = bunjiStripped.indexOf("-");
let bun1, bun2;
if (dashIdx !== -1) {
bun1 = bunjiStripped.slice(0, dashIdx);
bun2 = bunjiStripped.slice(dashIdx + 1);
} else {
bun1 = bunjiStripped;
bun2 = "";
}
// Validate bun1: must be numeric and at most 4 digits
if (!/^\d+$/.test(bun1)) {
throw makeError(
"INVALID_BUNJI",
`번지가 숫자가 아닙니다: "${bun1}"`,
400
);
}
if (bun1.length > 4) {
throw makeError(
"INVALID_BUNJI",
`번지가 너무 깁니다 (최대 4자리): "${bun1}"`,
400
);
}
return { sido, sidoCode, sigungu, eupmyeondong, san, bun1, bun2 };
}
// ---------------------------------------------------------------------------
// normalizeSearchResult
// ---------------------------------------------------------------------------
/**
* Normalizes a single raw item from realtyprice.kr's gsiList response.
*
* @param {{ base_year: string, gakuka_w: string, notice_ymd: string, [key: string]: any }} raw
* @returns {{ year: number, price_per_sqm: number|null, notice_date: string|null }}
*/
function normalizeSearchResult(raw) {
const year = parseInt(raw.base_year, 10);
const priceStr = raw.gakuka_w;
const price_per_sqm =
priceStr && priceStr.trim() !== ""
? parseInt(priceStr.replace(/,/g, ""), 10)
: null;
const ymd = raw.notice_ymd || "";
const notice_date =
ymd.length === 8
? `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}`
: null;
return { year, price_per_sqm, notice_date };
}
// ---------------------------------------------------------------------------
// buildResponse
// ---------------------------------------------------------------------------
const SOURCE_URL =
"https://www.realtyprice.kr/notice/gsindividual/search.htm";
/**
* Assembles the final normalized response from parsed address fields and a
* history array of normalized search results.
*
* @param {{ address: string, jibun: string, san: boolean, history: Array<{year: number, price_per_sqm: number|null, notice_date: string|null}> }} param
* @returns {object}
*/
function buildResponse({ address, jibun, san, history }) {
const sorted = [...history].sort((a, b) => b.year - a.year);
const latestRaw = sorted[0];
const latest = {
...latestRaw,
base_date: `${latestRaw.year}-01-01`,
};
let yoy_change_pct = null;
if (sorted.length >= 2) {
const latestPrice = sorted[0].price_per_sqm;
const prevPrice = sorted[1].price_per_sqm;
if (latestPrice !== null && prevPrice !== null && prevPrice !== 0) {
yoy_change_pct =
Math.round(((latestPrice - prevPrice) / prevPrice) * 100 * 100) / 100;
}
}
return {
address,
jibun,
san,
latest,
history: sorted,
yoy_change_pct,
source_url: SOURCE_URL,
};
}
// ---------------------------------------------------------------------------
// fetchWithTimeout
// ---------------------------------------------------------------------------
/**
* Wraps a fetch call with an AbortController timeout.
* @param {string} url
* @param {object} opts fetch options (headers, etc.)
* @param {number} [timeoutMs=30000]
* @param {Function} [fetchFn=fetch]
* @returns {Promise<Response>}
*/
async function fetchWithTimeout(url, opts = {}, timeoutMs = 30000, fetchFn = fetch) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetchFn(url, { ...opts, signal: controller.signal });
} catch (err) {
if (err.name === "AbortError") {
throw makeError(
"UPSTREAM_TIMEOUT",
"realtyprice.kr 응답 시간 초과 (30초)",
504
);
}
throw err;
} finally {
clearTimeout(timer);
}
}
// ---------------------------------------------------------------------------
// fetchSigunguList
// ---------------------------------------------------------------------------
/**
* Fetches the 시군구 list for a given 시도 code.
* @param {string} sidoCode 2-digit sido code (e.g. "11")
* @param {Function} [fetchFn=fetch]
* @returns {Promise<Array<{ code: string, name: string }>>}
*/
async function fetchSigunguList(sidoCode, fetchFn = fetch) {
const url = `${REALTYPRICE_BASE_URL}/bjd/searchBjdApi.bjd?gbn=1&gubun=sgg&sido=${sidoCode}`;
const res = await fetchWithTimeout(url, { headers: { Referer: REFERER } }, 30000, fetchFn);
if (!res.ok) {
throw makeError(
"UPSTREAM_ERROR",
`realtyprice.kr 시군구 조회 실패: HTTP ${res.status}`,
502
);
}
const data = await res.json();
const list = (data.model && data.model.list) || [];
return list.map((item) => ({
code: item.code,
name: item.name,
}));
}
// ---------------------------------------------------------------------------
// fetchEupmyeondongList
// ---------------------------------------------------------------------------
/**
* Fetches the 읍면동 list for a given 시도 + 시군구 code.
* @param {string} sidoCode 2-digit sido code (e.g. "11")
* @param {string} sggCode sigungu code (e.g. "11680")
* @param {Function} [fetchFn=fetch]
* @returns {Promise<Array<{ code: string, name: string }>>}
*/
async function fetchEupmyeondongList(sidoCode, sggCode, fetchFn = fetch) {
const url = `${REALTYPRICE_BASE_URL}/bjd/searchBjdApi.bjd?gbn=1&gubun=eub&sido=${sidoCode}&sgg=${sggCode}`;
const res = await fetchWithTimeout(url, { headers: { Referer: REFERER } }, 30000, fetchFn);
if (!res.ok) {
throw makeError(
"UPSTREAM_ERROR",
`realtyprice.kr 읍면동 조회 실패: HTTP ${res.status}`,
502
);
}
const data = await res.json();
const list = (data.model && data.model.list) || [];
return list.map((item) => ({
code: item.code,
name: item.name,
}));
}
// ---------------------------------------------------------------------------
// fetchGsiSearchList
// ---------------------------------------------------------------------------
/**
* Fetches the gsiList (공시지가 search results) for a given address.
* @param {{ regCode: string, eubCode: string, san: boolean, bun1: string, bun2: string }} params
* @param {Function} [fetchFn=fetch]
* @returns {Promise<Array>} raw gsiList items
*/
async function fetchGsiSearchList({ regCode, eubCode, san, bun1, bun2 }, fetchFn = fetch) {
const bun1Padded = bun1.padStart(4, "0");
const bun2Padded = bun2 ? bun2.padStart(4, "0") : "0000";
const sanParam = san ? "2" : "1";
const url =
`${REALTYPRICE_BASE_URL}/search/gsiSearchListApi.search` +
`?gbn=1&reg=${regCode}&eub=${eubCode}&san=${sanParam}` +
`&bun1=${bun1Padded}&bun2=${bun2Padded}&tabGbn=Text&page_no=1&year=`;
const res = await fetchWithTimeout(url, { headers: { Referer: REFERER } }, 30000, fetchFn);
if (!res.ok) {
throw makeError(
"UPSTREAM_ERROR",
`realtyprice.kr 공시지가 조회 실패: HTTP ${res.status}`,
502
);
}
const data = await res.json();
return (data.model && data.model.list) || [];
}
// ---------------------------------------------------------------------------
// lookupGongsijiga
// ---------------------------------------------------------------------------
/**
* Main orchestrator: resolves a raw Korean land address to its 공시지가 history.
*
* Flow:
* 1. parseAddress sidoCode, sigungu, eupmyeondong, san, bun1, bun2
* 2. fetchSigunguList find exact match on sigungu name
* 3. fetchEupmyeondongList match last token of eupmyeondong (exact then prefix)
* 4. fetchGsiSearchList normalize results buildResponse
*
* @param {string} addressRaw
* @param {Function} [fetchFn=fetch]
* @returns {Promise<object>}
*/
async function lookupGongsijiga(addressRaw, fetchFn = fetch) {
const { sidoCode, sigungu, eupmyeondong, san, bun1, bun2 } =
parseAddress(addressRaw);
let sggCode;
if (sidoCode === "29") {
sggCode = "36110";
} else {
const sggList = await fetchSigunguList(sidoCode, fetchFn);
const sggMatch = sggList.find((item) => item.name === sigungu);
if (!sggMatch) {
const candidates = sggList
.filter(
(item) => item.name.includes(sigungu) || sigungu.includes(item.name)
)
.slice(0, 3)
.map((item) => item.name);
const err = makeError(
"REGION_NOT_FOUND",
`시군구를 찾을 수 없습니다: "${sigungu}"`,
404
);
err.candidates = candidates;
throw err;
}
sggCode = sggMatch.code;
}
const eubList = await fetchEupmyeondongList(sidoCode, sggCode, fetchFn);
// The eupmyeondong from parseAddress may be multi-token (e.g. "청계면 청천리").
// The API may return names like "청계면 청천리" (combined) or just "역삼동" (single).
// Try full string match first, then last-token match.
const eubTokens = eupmyeondong.split(/\s+/);
const eubTarget = eubTokens[eubTokens.length - 1];
// Try exact match on full eupmyeondong string first (handles "청계면 청천리" format)
let eubMatch = eubList.find((item) => item.name === eupmyeondong);
// Then try exact match on last token only (handles "역삼동" format)
if (!eubMatch) {
eubMatch = eubList.find((item) => item.name === eubTarget);
}
// Then prefix/contains match: strip trailing 동/리/면/읍 suffix from target and compare
if (!eubMatch) {
const eubStem = eubTarget.replace(/[동리면읍]$/, "");
const prefixMatches = eubList.filter(
(item) =>
item.name === eubTarget ||
item.name.startsWith(eubStem) ||
item.name.endsWith(eubTarget) ||
item.name === eupmyeondong
);
if (prefixMatches.length === 1) {
eubMatch = prefixMatches[0];
} else {
// no match or ambiguous
const candidates = eubList
.filter(
(item) =>
item.name.includes(eubTarget) || eubTarget.includes(item.name)
)
.slice(0, 3)
.map((item) => item.name);
const err = makeError(
"REGION_NOT_FOUND",
`읍면동을 찾을 수 없습니다: "${eupmyeondong}"`,
404
);
err.candidates = candidates;
throw err;
}
}
// Step 4: fetch gsiList
const gsiListRaw = await fetchGsiSearchList(
{ regCode: sggCode, eubCode: eubMatch.code, san, bun1, bun2 },
fetchFn
);
if (!gsiListRaw || gsiListRaw.length === 0) {
throw makeError(
"LAND_NOT_FOUND",
"해당 지번의 공시지가가 등재되지 않았습니다. 본번/부번 오타이거나 도로/하천 등 미과세 토지일 수 있습니다.",
404
);
}
// Step 5: normalize + build response
const history = gsiListRaw.map(normalizeSearchResult);
const jibun = bun2 ? `${bun1}-${bun2}번지` : `${bun1}번지`;
return buildResponse({ address: addressRaw.trim(), jibun, san, history });
}
// ---------------------------------------------------------------------------
// createCache
// ---------------------------------------------------------------------------
/**
* Creates a simple in-memory TTL cache backed by a Map.
* No LRU, no max-size, no periodic cleanup.
*
* @returns {{ get(key: string): any|null, set(key: string, value: any, ttlMs: number): void, size(): number }}
*/
function createCache() {
const store = new Map();
return {
get(key) {
const entry = store.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
store.delete(key);
return null;
}
return entry.value;
},
set(key, value, ttlMs) {
store.set(key, { value, expiresAt: Date.now() + ttlMs });
},
size() {
return store.size;
},
};
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
module.exports = {
REALTYPRICE_BASE_URL,
REFERER,
SIDO_MAP,
makeError,
parseSido,
parseAddress,
normalizeSearchResult,
buildResponse,
fetchWithTimeout,
fetchSigunguList,
fetchEupmyeondongList,
fetchGsiSearchList,
lookupGongsijiga,
createCache,
};

View file

@ -0,0 +1,984 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
SIDO_MAP,
REALTYPRICE_BASE_URL,
REFERER,
makeError,
parseSido,
parseAddress,
normalizeSearchResult,
buildResponse,
fetchWithTimeout,
fetchSigunguList,
fetchEupmyeondongList,
fetchGsiSearchList,
lookupGongsijiga,
createCache,
} = require("../src/realtyprice");
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
test("REALTYPRICE_BASE_URL is correct", () => {
assert.equal(REALTYPRICE_BASE_URL, "https://www.realtyprice.kr/notice");
});
test("REFERER is correct", () => {
assert.equal(
REFERER,
"https://www.realtyprice.kr/notice/gsindividual/search.htm"
);
});
test("SIDO_MAP has 17 entries", () => {
const uniqueCodes = new Set(Object.values(SIDO_MAP));
assert.equal(uniqueCodes.size, 17);
});
// ---------------------------------------------------------------------------
// makeError
// ---------------------------------------------------------------------------
test("makeError attaches code and statusCode", () => {
const err = makeError("ADDRESS_PARSE_FAILED", "bad address", 400);
assert.equal(err.message, "bad address");
assert.equal(err.code, "ADDRESS_PARSE_FAILED");
assert.equal(err.statusCode, 400);
assert.ok(err instanceof Error);
});
// ---------------------------------------------------------------------------
// parseSido — full names
// ---------------------------------------------------------------------------
test("parseSido: 서울특별시 → 11", () => {
assert.equal(parseSido("서울특별시"), "11");
});
test("parseSido: 부산광역시 → 21", () => {
assert.equal(parseSido("부산광역시"), "21");
});
test("parseSido: 대구광역시 → 22", () => {
assert.equal(parseSido("대구광역시"), "22");
});
test("parseSido: 인천광역시 → 23", () => {
assert.equal(parseSido("인천광역시"), "23");
});
test("parseSido: 광주광역시 → 24", () => {
assert.equal(parseSido("광주광역시"), "24");
});
test("parseSido: 대전광역시 → 25", () => {
assert.equal(parseSido("대전광역시"), "25");
});
test("parseSido: 울산광역시 → 26", () => {
assert.equal(parseSido("울산광역시"), "26");
});
test("parseSido: 세종특별자치시 → 29", () => {
assert.equal(parseSido("세종특별자치시"), "29");
});
test("parseSido: 경기도 → 41", () => {
assert.equal(parseSido("경기도"), "41");
});
test("parseSido: 강원특별자치도 → 42", () => {
assert.equal(parseSido("강원특별자치도"), "42");
});
test("parseSido: 강원도 → 42", () => {
assert.equal(parseSido("강원도"), "42");
});
test("parseSido: 충청북도 → 43", () => {
assert.equal(parseSido("충청북도"), "43");
});
test("parseSido: 충청남도 → 44", () => {
assert.equal(parseSido("충청남도"), "44");
});
test("parseSido: 전북특별자치도 → 45", () => {
assert.equal(parseSido("전북특별자치도"), "45");
});
test("parseSido: 전라북도 → 45", () => {
assert.equal(parseSido("전라북도"), "45");
});
test("parseSido: 전라남도 → 46", () => {
assert.equal(parseSido("전라남도"), "46");
});
test("parseSido: 경상북도 → 47", () => {
assert.equal(parseSido("경상북도"), "47");
});
test("parseSido: 경상남도 → 48", () => {
assert.equal(parseSido("경상남도"), "48");
});
test("parseSido: 제주특별자치도 → 50", () => {
assert.equal(parseSido("제주특별자치도"), "50");
});
test("parseSido: 제주도 → 50", () => {
assert.equal(parseSido("제주도"), "50");
});
// ---------------------------------------------------------------------------
// parseSido — abbreviations
// ---------------------------------------------------------------------------
test("parseSido: 서울 → 11", () => {
assert.equal(parseSido("서울"), "11");
});
test("parseSido: 부산 → 21", () => {
assert.equal(parseSido("부산"), "21");
});
test("parseSido: 대구 → 22", () => {
assert.equal(parseSido("대구"), "22");
});
test("parseSido: 인천 → 23", () => {
assert.equal(parseSido("인천"), "23");
});
test("parseSido: 광주 → 24", () => {
assert.equal(parseSido("광주"), "24");
});
test("parseSido: 대전 → 25", () => {
assert.equal(parseSido("대전"), "25");
});
test("parseSido: 울산 → 26", () => {
assert.equal(parseSido("울산"), "26");
});
test("parseSido: 세종 → 29", () => {
assert.equal(parseSido("세종"), "29");
});
test("parseSido: 경기 → 41", () => {
assert.equal(parseSido("경기"), "41");
});
test("parseSido: 강원 → 42", () => {
assert.equal(parseSido("강원"), "42");
});
test("parseSido: 충북 → 43", () => {
assert.equal(parseSido("충북"), "43");
});
test("parseSido: 충남 → 44", () => {
assert.equal(parseSido("충남"), "44");
});
test("parseSido: 전북 → 45", () => {
assert.equal(parseSido("전북"), "45");
});
test("parseSido: 전남 → 46", () => {
assert.equal(parseSido("전남"), "46");
});
test("parseSido: 경북 → 47", () => {
assert.equal(parseSido("경북"), "47");
});
test("parseSido: 경남 → 48", () => {
assert.equal(parseSido("경남"), "48");
});
test("parseSido: 제주 → 50", () => {
assert.equal(parseSido("제주"), "50");
});
// ---------------------------------------------------------------------------
// parseSido — unknown
// ---------------------------------------------------------------------------
test("parseSido: unknown string → null", () => {
assert.equal(parseSido("미국"), null);
});
test("parseSido: empty string → null", () => {
assert.equal(parseSido(""), null);
});
test("parseSido: random word → null", () => {
assert.equal(parseSido("역삼동"), null);
});
// ---------------------------------------------------------------------------
// parseAddress — success cases
// ---------------------------------------------------------------------------
test("parseAddress: full address 서울특별시 강남구 역삼동 736", () => {
const result = parseAddress("서울특별시 강남구 역삼동 736");
assert.equal(result.sido, "서울특별시");
assert.equal(result.sidoCode, "11");
assert.equal(result.sigungu, "강남구");
assert.equal(result.eupmyeondong, "역삼동");
assert.equal(result.san, false);
assert.equal(result.bun1, "736");
assert.equal(result.bun2, "");
});
test("parseAddress: abbreviated sido 서울 강남구 역삼동 736", () => {
const result = parseAddress("서울 강남구 역삼동 736");
assert.equal(result.sido, "서울");
assert.equal(result.sidoCode, "11");
assert.equal(result.sigungu, "강남구");
assert.equal(result.eupmyeondong, "역삼동");
assert.equal(result.san, false);
assert.equal(result.bun1, "736");
assert.equal(result.bun2, "");
});
test("parseAddress: san keyword with space 서울 서초구 서초동 산 1-2", () => {
const result = parseAddress("서울 서초구 서초동 산 1-2");
assert.equal(result.sido, "서울");
assert.equal(result.sidoCode, "11");
assert.equal(result.sigungu, "서초구");
assert.equal(result.eupmyeondong, "서초동");
assert.equal(result.san, true);
assert.equal(result.bun1, "1");
assert.equal(result.bun2, "2");
});
test("parseAddress: san keyword attached 서울 서초구 서초동 산1-2", () => {
const result = parseAddress("서울 서초구 서초동 산1-2");
assert.equal(result.san, true);
assert.equal(result.bun1, "1");
assert.equal(result.bun2, "2");
});
test("parseAddress: multi-token eupmyeondong 전라남도 무안군 청계면 청천리 100-5", () => {
const result = parseAddress("전라남도 무안군 청계면 청천리 100-5");
assert.equal(result.sido, "전라남도");
assert.equal(result.sidoCode, "46");
assert.equal(result.sigungu, "무안군");
assert.equal(result.eupmyeondong, "청계면 청천리");
assert.equal(result.san, false);
assert.equal(result.bun1, "100");
assert.equal(result.bun2, "5");
});
test("parseAddress: bun1-bun2 split on dash 경기 성남시 분당구 정자동 100-5", () => {
const result = parseAddress("경기 성남시 분당구 정자동 100-5");
assert.equal(result.sidoCode, "41");
assert.equal(result.sigungu, "성남시");
assert.equal(result.eupmyeondong, "분당구 정자동");
assert.equal(result.bun1, "100");
assert.equal(result.bun2, "5");
});
test("parseAddress: no bun2 when single number 부산 해운대구 좌동 1", () => {
const result = parseAddress("부산 해운대구 좌동 1");
assert.equal(result.sidoCode, "21");
assert.equal(result.bun1, "1");
assert.equal(result.bun2, "");
});
test("parseAddress: trailing 번지 removed 서울 강남구 역삼동 736번지", () => {
const result = parseAddress("서울 강남구 역삼동 736번지");
assert.equal(result.bun1, "736");
assert.equal(result.bun2, "");
});
test("parseAddress: trailing 번지 removed with dash 서울 강남구 역삼동 100-5번지", () => {
const result = parseAddress("서울 강남구 역삼동 100-5번지");
assert.equal(result.bun1, "100");
assert.equal(result.bun2, "5");
});
test("parseAddress: 세종 address without sigungu 세종 조치원읍 신흥리 100", () => {
const result = parseAddress("세종 조치원읍 신흥리 100");
assert.equal(result.sidoCode, "29");
assert.equal(result.sido, "세종");
assert.equal(result.sigungu, "");
assert.equal(result.eupmyeondong, "조치원읍 신흥리");
assert.equal(result.san, false);
assert.equal(result.bun1, "100");
assert.equal(result.bun2, "");
});
test("parseAddress: 세종 full name 세종특별자치시 고욘동 100", () => {
const result = parseAddress("세종특별자치시 고욘동 100");
assert.equal(result.sidoCode, "29");
assert.equal(result.sido, "세종특별자치시");
assert.equal(result.sigungu, "");
assert.equal(result.eupmyeondong, "고욘동");
assert.equal(result.bun1, "100");
});
// ---------------------------------------------------------------------------
// parseAddress — error cases
// ---------------------------------------------------------------------------
test("parseAddress: missing sido throws ADDRESS_PARSE_FAILED", () => {
assert.throws(
() => parseAddress("역삼동 736"),
(err) => {
assert.equal(err.code, "ADDRESS_PARSE_FAILED");
assert.equal(err.statusCode, 400);
return true;
}
);
});
test("parseAddress: unrecognized sido throws ADDRESS_PARSE_FAILED", () => {
assert.throws(
() => parseAddress("뉴욕시 맨해튼구 어딘가동 1"),
(err) => {
assert.equal(err.code, "ADDRESS_PARSE_FAILED");
assert.equal(err.statusCode, 400);
return true;
}
);
});
test("parseAddress: empty string throws ADDRESS_PARSE_FAILED", () => {
assert.throws(
() => parseAddress(""),
(err) => {
assert.equal(err.code, "ADDRESS_PARSE_FAILED");
assert.equal(err.statusCode, 400);
return true;
}
);
});
test("parseAddress: bun1 over 4 digits throws INVALID_BUNJI", () => {
assert.throws(
() => parseAddress("서울 강남구 역삼동 12345"),
(err) => {
assert.equal(err.code, "INVALID_BUNJI");
assert.equal(err.statusCode, 400);
return true;
}
);
});
test("parseAddress: non-numeric bun1 throws INVALID_BUNJI", () => {
assert.throws(
() => parseAddress("서울 강남구 역삼동 abc"),
(err) => {
assert.equal(err.code, "INVALID_BUNJI");
assert.equal(err.statusCode, 400);
return true;
}
);
});
test("parseAddress: non-numeric bun1 with dash throws INVALID_BUNJI", () => {
assert.throws(
() => parseAddress("서울 강남구 역삼동 abc-5"),
(err) => {
assert.equal(err.code, "INVALID_BUNJI");
assert.equal(err.statusCode, 400);
return true;
}
);
});
// ---------------------------------------------------------------------------
// normalizeSearchResult
// ---------------------------------------------------------------------------
test("normalizeSearchResult: parses price with commas", () => {
const raw = {
base_year: "2026",
gakuka_w: "72,340,000",
notice_ymd: "20260430",
x_coord: "127.12345",
y_coord: "37.98765",
};
const result = normalizeSearchResult(raw);
assert.equal(result.price_per_sqm, 72340000);
});
test("normalizeSearchResult: formats notice_ymd as YYYY-MM-DD", () => {
const raw = {
base_year: "2026",
gakuka_w: "72,340,000",
notice_ymd: "20260430",
x_coord: "127.12345",
y_coord: "37.98765",
};
const result = normalizeSearchResult(raw);
assert.equal(result.notice_date, "2026-04-30");
});
test("normalizeSearchResult: extracts year as integer", () => {
const raw = {
base_year: "2026",
gakuka_w: "72,340,000",
notice_ymd: "20260430",
x_coord: "127.12345",
y_coord: "37.98765",
};
const result = normalizeSearchResult(raw);
assert.equal(result.year, 2026);
assert.equal(typeof result.year, "number");
});
test("normalizeSearchResult: does NOT include x_coord or y_coord in output", () => {
const raw = {
base_year: "2026",
gakuka_w: "72,340,000",
notice_ymd: "20260430",
x_coord: "127.12345",
y_coord: "37.98765",
};
const result = normalizeSearchResult(raw);
assert.ok(!("x_coord" in result));
assert.ok(!("y_coord" in result));
});
test("normalizeSearchResult: missing gakuka_w → price_per_sqm is null", () => {
const raw = {
base_year: "2026",
gakuka_w: "",
notice_ymd: "20260430",
};
const result = normalizeSearchResult(raw);
assert.equal(result.price_per_sqm, null);
});
test("normalizeSearchResult: notice_ymd shorter than 8 chars → notice_date is null", () => {
const raw = {
base_year: "2026",
gakuka_w: "72,340,000",
notice_ymd: "2026",
};
const result = normalizeSearchResult(raw);
assert.equal(result.notice_date, null);
});
// ---------------------------------------------------------------------------
// buildResponse
// ---------------------------------------------------------------------------
test("buildResponse: computes yoy_change_pct correctly for 2+ years", () => {
const history = [
{ year: 2025, price_per_sqm: 68600000, notice_date: "2025-04-30" },
{ year: 2026, price_per_sqm: 72340000, notice_date: "2026-04-30" },
];
const result = buildResponse({
address: "서울 강남구 역삼동 736",
jibun: "736번지",
san: false,
history,
});
assert.equal(result.yoy_change_pct, 5.45);
});
test("buildResponse: yoy_change_pct is null when only 1 year", () => {
const history = [
{ year: 2026, price_per_sqm: 72340000, notice_date: "2026-04-30" },
];
const result = buildResponse({
address: "서울 강남구 역삼동 736",
jibun: "736번지",
san: false,
history,
});
assert.equal(result.yoy_change_pct, null);
});
test("buildResponse: history is sorted descending by year", () => {
const history = [
{ year: 2024, price_per_sqm: 65000000, notice_date: "2024-04-30" },
{ year: 2026, price_per_sqm: 72340000, notice_date: "2026-04-30" },
{ year: 2025, price_per_sqm: 68600000, notice_date: "2025-04-30" },
];
const result = buildResponse({
address: "서울 강남구 역삼동 736",
jibun: "736번지",
san: false,
history,
});
assert.equal(result.history[0].year, 2026);
assert.equal(result.history[1].year, 2025);
assert.equal(result.history[2].year, 2024);
});
test("buildResponse: latest has base_date set to {year}-01-01", () => {
const history = [
{ year: 2026, price_per_sqm: 72340000, notice_date: "2026-04-30" },
];
const result = buildResponse({
address: "서울 강남구 역삼동 736",
jibun: "736번지",
san: false,
history,
});
assert.equal(result.latest.base_date, "2026-01-01");
assert.equal(result.latest.year, 2026);
assert.equal(result.latest.price_per_sqm, 72340000);
});
test("buildResponse: source_url is correct constant", () => {
const history = [
{ year: 2026, price_per_sqm: 72340000, notice_date: "2026-04-30" },
];
const result = buildResponse({
address: "서울 강남구 역삼동 736",
jibun: "736번지",
san: false,
history,
});
assert.equal(
result.source_url,
"https://www.realtyprice.kr/notice/gsindividual/search.htm"
);
});
test("buildResponse: output shape includes address, jibun, san", () => {
const history = [
{ year: 2026, price_per_sqm: 72340000, notice_date: "2026-04-30" },
];
const result = buildResponse({
address: "서울 강남구 역삼동 736",
jibun: "736번지",
san: false,
history,
});
assert.equal(result.address, "서울 강남구 역삼동 736");
assert.equal(result.jibun, "736번지");
assert.equal(result.san, false);
});
// ---------------------------------------------------------------------------
// fetchSigunguList
// ---------------------------------------------------------------------------
test("fetchSigunguList: URL includes gubun=sgg and sido=11", async () => {
let capturedUrl;
const mockFetch = async (url, opts) => {
capturedUrl = url;
return {
ok: true,
json: async () => ({
model: { list: [
{ code: "11680", name: "강남구" },
] },
}),
};
};
await fetchSigunguList("11", mockFetch);
assert.ok(capturedUrl.includes("gubun=sgg"), `URL should include gubun=sgg, got: ${capturedUrl}`);
assert.ok(capturedUrl.includes("sido=11"), `URL should include sido=11, got: ${capturedUrl}`);
});
test("fetchSigunguList: Referer header is present", async () => {
let capturedOpts;
const mockFetch = async (url, opts) => {
capturedOpts = opts;
return {
ok: true,
json: async () => ({ model: { list: [] } }),
};
};
await fetchSigunguList("11", mockFetch);
assert.equal(capturedOpts.headers.Referer, REFERER);
});
test("fetchSigunguList: parses model.list and returns mapped array", async () => {
const mockFetch = async () => ({
ok: true,
json: async () => ({
model: { list: [
{ code: "11680", name: "강남구" },
{ code: "11650", name: "서초구" },
] },
}),
});
const result = await fetchSigunguList("11", mockFetch);
assert.equal(result.length, 2);
assert.deepEqual(result[0], { code: "11680", name: "강남구" });
assert.deepEqual(result[1], { code: "11650", name: "서초구" });
});
test("fetchSigunguList: HTTP 500 → throws UPSTREAM_ERROR with statusCode 502", async () => {
const mockFetch = async () => ({
ok: false,
status: 500,
});
await assert.rejects(
() => fetchSigunguList("11", mockFetch),
(err) => {
assert.equal(err.code, "UPSTREAM_ERROR");
assert.equal(err.statusCode, 502);
return true;
}
);
});
// ---------------------------------------------------------------------------
// fetchEupmyeondongList
// ---------------------------------------------------------------------------
test("fetchEupmyeondongList: URL includes gubun=eub, sido=11, sgg=11680", async () => {
let capturedUrl;
const mockFetch = async (url, opts) => {
capturedUrl = url;
return {
ok: true,
json: async () => ({ model: { list: [] } }),
};
};
await fetchEupmyeondongList("11", "11680", mockFetch);
assert.ok(capturedUrl.includes("gubun=eub"), `URL should include gubun=eub, got: ${capturedUrl}`);
assert.ok(capturedUrl.includes("sido=11"), `URL should include sido=11, got: ${capturedUrl}`);
assert.ok(capturedUrl.includes("sgg=11680"), `URL should include sgg=11680, got: ${capturedUrl}`);
});
test("fetchEupmyeondongList: parses model.list and returns mapped array", async () => {
const mockFetch = async () => ({
ok: true,
json: async () => ({
model: { list: [
{ code: "10100", name: "역삼동" },
] },
}),
});
const result = await fetchEupmyeondongList("11", "11680", mockFetch);
assert.equal(result.length, 1);
assert.deepEqual(result[0], { code: "10100", name: "역삼동" });
});
// ---------------------------------------------------------------------------
// fetchGsiSearchList
// ---------------------------------------------------------------------------
test("fetchGsiSearchList: URL includes reg, eub, san=1, bun1=0736", async () => {
let capturedUrl;
const mockFetch = async (url, opts) => {
capturedUrl = url;
return {
ok: true,
json: async () => ({ model: { list: [] } }),
};
};
await fetchGsiSearchList(
{ regCode: "11680", eubCode: "11680101", san: false, bun1: "736", bun2: "" },
mockFetch
);
assert.ok(capturedUrl.includes("reg=11680"), `URL should include reg=11680, got: ${capturedUrl}`);
assert.ok(capturedUrl.includes("eub=11680101"), `URL should include eub=11680101, got: ${capturedUrl}`);
assert.ok(capturedUrl.includes("san=1"), `URL should include san=1, got: ${capturedUrl}`);
assert.ok(capturedUrl.includes("bun1=0736"), `URL should include bun1=0736, got: ${capturedUrl}`);
});
test("fetchGsiSearchList: san=true → san=2 in URL", async () => {
let capturedUrl;
const mockFetch = async (url, opts) => {
capturedUrl = url;
return {
ok: true,
json: async () => ({ model: { list: [] } }),
};
};
await fetchGsiSearchList(
{ regCode: "11680", eubCode: "11680101", san: true, bun1: "1", bun2: "" },
mockFetch
);
assert.ok(capturedUrl.includes("san=2"), `URL should include san=2, got: ${capturedUrl}`);
});
test("fetchGsiSearchList: bun2=5 → bun2=0005 in URL", async () => {
let capturedUrl;
const mockFetch = async (url, opts) => {
capturedUrl = url;
return {
ok: true,
json: async () => ({ model: { list: [] } }),
};
};
await fetchGsiSearchList(
{ regCode: "11680", eubCode: "11680101", san: false, bun1: "736", bun2: "5" },
mockFetch
);
assert.ok(capturedUrl.includes("bun2=0005"), `URL should include bun2=0005, got: ${capturedUrl}`);
});
test("fetchGsiSearchList: returns raw list array", async () => {
const rawItems = [
{ base_year: "2026", gakuka_w: "72,340,000", notice_ymd: "20260430" },
];
const mockFetch = async () => ({
ok: true,
json: async () => ({ model: { list: rawItems } }),
});
const result = await fetchGsiSearchList(
{ regCode: "11680", eubCode: "11680101", san: false, bun1: "736", bun2: "" },
mockFetch
);
assert.deepEqual(result, rawItems);
});
test("fetchGsiSearchList: HTTP error → throws UPSTREAM_ERROR with statusCode 502", async () => {
const mockFetch = async () => ({ ok: false, status: 503 });
await assert.rejects(
() => fetchGsiSearchList(
{ regCode: "11680", eubCode: "11680101", san: false, bun1: "736", bun2: "" },
mockFetch
),
(err) => {
assert.equal(err.code, "UPSTREAM_ERROR");
assert.equal(err.statusCode, 502);
return true;
}
);
});
// ---------------------------------------------------------------------------
// fetchWithTimeout
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// lookupGongsijiga
// ---------------------------------------------------------------------------
// Shared mock data
const MOCK_SGG_LIST = [
{ code: "11680", name: "강남구" },
{ code: "11650", name: "서초구" },
{ code: "11440", name: "마포구" },
];
const MOCK_EUB_LIST = [
{ code: "10100", name: "역삼동" },
{ code: "10500", name: "삼성동" },
];
const MOCK_GSI_LIST = [
{ base_year: "2025", gakuka_w: "68,600,000", notice_ymd: "20250430" },
{ base_year: "2026", gakuka_w: "72,340,000", notice_ymd: "20260430" },
];
function makeMockFetch({ sggList = MOCK_SGG_LIST, eubList = MOCK_EUB_LIST, gsiList = MOCK_GSI_LIST } = {}) {
return async (url) => {
if (url.includes("gubun=sgg")) {
return { ok: true, json: async () => ({ model: { list: sggList } }) };
}
if (url.includes("gubun=eub")) {
return { ok: true, json: async () => ({ model: { list: eubList } }) };
}
// gsiSearchList
return { ok: true, json: async () => ({ model: { list: gsiList } }) };
};
}
test("lookupGongsijiga: success — returns full response shape", async () => {
const result = await lookupGongsijiga(
"서울특별시 강남구 역삼동 736",
makeMockFetch()
);
assert.equal(result.address, "서울특별시 강남구 역삼동 736");
assert.equal(result.jibun, "736번지");
assert.equal(result.san, false);
assert.ok(Array.isArray(result.history));
assert.equal(result.history.length, 2);
// sorted descending
assert.equal(result.history[0].year, 2026);
assert.equal(result.history[1].year, 2025);
assert.ok("latest" in result);
assert.equal(result.latest.year, 2026);
assert.ok("yoy_change_pct" in result);
assert.equal(
result.source_url,
"https://www.realtyprice.kr/notice/gsindividual/search.htm"
);
});
test("lookupGongsijiga: success with bun2 — jibun is bun1-bun2번지", async () => {
const result = await lookupGongsijiga(
"서울특별시 강남구 역삼동 100-5",
makeMockFetch()
);
assert.equal(result.jibun, "100-5번지");
});
test("lookupGongsijiga: success for Sejong (no sigungu, fixed sggCode)", async () => {
const sejongEubList = [
{ code: "25029", name: "조치원읍 신흥리" },
];
const result = await lookupGongsijiga(
"세종 조치원읍 신흥리 100",
makeMockFetch({ eubList: sejongEubList })
);
assert.equal(result.address, "세종 조치원읍 신흥리 100");
assert.equal(result.jibun, "100번지");
assert.equal(result.san, false);
assert.ok(Array.isArray(result.history));
});
test("lookupGongsijiga: REGION_NOT_FOUND when sigungu not in list", async () => {
await assert.rejects(
() => lookupGongsijiga("서울특별시 종로구 역삼동 736", makeMockFetch()),
(err) => {
assert.equal(err.code, "REGION_NOT_FOUND");
assert.equal(err.statusCode, 404);
assert.ok(Array.isArray(err.candidates));
return true;
}
);
});
test("lookupGongsijiga: REGION_NOT_FOUND candidates are up to 3 suggestions", async () => {
// "강남" is a prefix of "강남구" → should appear as candidate
const sggList = [
{ code: "A", name: "강남A구" },
{ code: "B", name: "강남B구" },
{ code: "C", name: "강남C구" },
{ code: "D", name: "강남D구" },
{ code: "E", name: "전혀무관구" },
];
await assert.rejects(
() =>
lookupGongsijiga(
"서울특별시 강남구 역삼동 736",
makeMockFetch({ sggList })
),
(err) => {
assert.equal(err.code, "REGION_NOT_FOUND");
assert.ok(err.candidates.length <= 3);
return true;
}
);
});
test("lookupGongsijiga: REGION_NOT_FOUND when eupmyeondong not in list", async () => {
await assert.rejects(
() =>
lookupGongsijiga(
"서울특별시 강남구 없는동 736",
makeMockFetch()
),
(err) => {
assert.equal(err.code, "REGION_NOT_FOUND");
assert.equal(err.statusCode, 404);
assert.ok(Array.isArray(err.candidates));
return true;
}
);
});
test("lookupGongsijiga: LAND_NOT_FOUND when gsiList is empty", async () => {
await assert.rejects(
() =>
lookupGongsijiga(
"서울특별시 강남구 역삼동 736",
makeMockFetch({ gsiList: [] })
),
(err) => {
assert.equal(err.code, "LAND_NOT_FOUND");
assert.equal(err.statusCode, 404);
assert.ok(
err.message.includes("공시지가가 등재되지 않았습니다")
);
return true;
}
);
});
test("lookupGongsijiga: eupmyeondong multi-token uses last token for match", async () => {
// "청계면 청천리" → last token "청천리" must match eub list entry "청천리"
const eubList = [
{ code: "46130310", name: "청천리" },
];
const sggList = [
{ code: "46130", name: "무안군" },
];
const result = await lookupGongsijiga(
"전라남도 무안군 청계면 청천리 100-5",
makeMockFetch({ sggList, eubList })
);
assert.equal(result.jibun, "100-5번지");
});
test("lookupGongsijiga: eupmyeondong prefix match (strip suffix) resolves single match", async () => {
// "역삼동" stem "역삼" → matches "역삼동" in list
const eubList = [
{ code: "10100", name: "역삼동" },
{ code: "10500", name: "삼성동" },
];
const result = await lookupGongsijiga(
"서울특별시 강남구 역삼동 736",
makeMockFetch({ eubList })
);
assert.equal(result.address, "서울특별시 강남구 역삼동 736");
});
// ---------------------------------------------------------------------------
// createCache
// ---------------------------------------------------------------------------
test("createCache: get returns null for missing key", () => {
const cache = createCache();
assert.equal(cache.get("nonexistent"), null);
});
test("createCache: set then get returns value within TTL", () => {
const cache = createCache();
cache.set("key1", { data: 42 }, 10000);
assert.deepEqual(cache.get("key1"), { data: 42 });
});
test("createCache: get returns null after TTL expires", async () => {
const cache = createCache();
cache.set("key2", "hello", 1);
// wait long enough for the 1ms TTL to pass
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(cache.get("key2"), null);
});
test("createCache: size() returns correct count", () => {
const cache = createCache();
assert.equal(cache.size(), 0);
cache.set("a", 1, 10000);
assert.equal(cache.size(), 1);
cache.set("b", 2, 10000);
assert.equal(cache.size(), 2);
});
// ---------------------------------------------------------------------------
// fetchWithTimeout
// ---------------------------------------------------------------------------
test("fetchWithTimeout: simulated slow fetch → UPSTREAM_TIMEOUT with statusCode 504", async () => {
const slowFetch = (url, opts) =>
new Promise((resolve, reject) => {
opts.signal.addEventListener("abort", () => {
const err = new Error("The operation was aborted");
err.name = "AbortError";
reject(err);
});
// never resolves on its own
});
await assert.rejects(
() => fetchWithTimeout("https://example.com", {}, 10, slowFetch),
(err) => {
assert.equal(err.code, "UPSTREAM_TIMEOUT");
assert.equal(err.statusCode, 504);
return true;
}
);
});

View file

@ -1695,7 +1695,6 @@ test("package-lock captures the toss-securities workspace metadata for npm ci",
resolved: "packages/toss-securities",
link: true,
});
assert.equal(packageLock.packages["packages/toss-securities"].version, "0.2.0");
assert.equal(packageLock.packages["packages/toss-securities"].license, "MIT");
assert.equal(packageLock.packages["packages/toss-securities"].engines.node, ">=18");
});