mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
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:
parent
32162e4cd7
commit
2ff51db5d2
15 changed files with 2017 additions and 8 deletions
7
.changeset/gongsijiga-search-package.md
Normal file
7
.changeset/gongsijiga-search-package.md
Normal 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.
|
||||
5
.changeset/gongsijiga-search.md
Normal file
5
.changeset/gongsijiga-search.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"k-skill-proxy": patch
|
||||
---
|
||||
|
||||
refactor: remove realtyprice route (moved to standalone gongsijiga-search package)
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 코드에서 직접 호출하고 프록시를 거치지 않는다.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
93
docs/features/gongsijiga-search.md
Normal file
93
docs/features/gongsijiga-search.md
Normal 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)
|
||||
|
|
@ -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
145
gongsijiga-search/SKILL.md
Normal 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
23
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
100
packages/gongsijiga-search/README.md
Normal file
100
packages/gongsijiga-search/README.md
Normal 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` 헤더가 필요하며, 이 패키지가 자동 처리합니다.
|
||||
33
packages/gongsijiga-search/package.json
Normal file
33
packages/gongsijiga-search/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
41
packages/gongsijiga-search/src/index.js
Normal file
41
packages/gongsijiga-search/src/index.js
Normal 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,
|
||||
};
|
||||
585
packages/gongsijiga-search/src/realtyprice.js
Normal file
585
packages/gongsijiga-search/src/realtyprice.js
Normal 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®=${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,
|
||||
};
|
||||
984
packages/gongsijiga-search/test/realtyprice.test.js
Normal file
984
packages/gongsijiga-search/test/realtyprice.test.js
Normal 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;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue