Add lh-notice-search skill and /v1/lh-notice/{search,detail} proxy routes

Wraps the official data.go.kr LH (Korea Land & Housing Corporation) 청약
공고 Open API (B552555/lhLeaseNoticeInfo1/*) so agents can look up LH
임대/분양/주거복지/토지/상가 공고 by region, status, category, keyword,
and notice ID without asking users for a ServiceKey. Reuses the shared
DATA_GO_KR_API_KEY the proxy already manages; users see '불필요'.

Adapter handles both the LH-specific [CMN, dsList] JSON envelope and the
standard data.go.kr <OpenAPI_ServiceResponse> XML error envelope; refuses
to cache failure responses so transient upstream errors self-heal.

Closes #145.
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-22 10:58:03 +09:00
commit 617a025931
12 changed files with 1837 additions and 2 deletions

View file

@ -0,0 +1,5 @@
---
"k-skill-proxy": minor
---
Add `/v1/lh-notice/search` and `/v1/lh-notice/detail` routes plus matching `lh-notice-search` skill. Proxies the official LH 청약 (Korea Land & Housing Corporation lease/subscription) notice API on `apis.data.go.kr/B552555/lhLeaseNoticeInfo1/*`, reuses the existing `DATA_GO_KR_API_KEY`, and keeps the user-facing credential surface empty ("불필요"). Handles the LH-specific `[CMN, dsList]` JSON envelope plus the standard data.go.kr XML auth-error envelope, does not cache upstream failures, and exposes `lhNoticeConfigured` on `/health`. Closes #145.

View file

@ -32,6 +32,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 한국 법령 검색 | 한국 법령/조문/판례/유권해석 검색 | 불필요 | [한국 법령 검색 가이드](docs/features/korean-law-search.md) |
| 한국 개인정보처리방침·이용약관 자동 생성 | Next.js 프로젝트에 개인정보보호법·약관규제법·전자상거래법 기반 개인정보처리방침/이용약관/쿠키 배너/동의 모달을 생성하는 `kimlawtech/korean-privacy-terms` (Apache-2.0) thin wrapper | 불필요 | [한국 개인정보처리방침·이용약관 자동 생성 가이드](docs/features/korean-privacy-terms.md) |
| 한국 부동산 실거래가 조회 | 아파트/오피스텔/빌라/단독주택 실거래가·전월세·지역코드 조회 | 불필요 | [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md) |
| LH 청약 공고문 조회 | 한국토지주택공사(LH) 임대/분양/주거복지(신혼희망타운)/토지/상가 공고를 지역·상태·공고유형·키워드로 조회하고 마감 여부를 KST 기준으로 표시 | 불필요 | [LH 청약 공고문 조회 가이드](docs/features/lh-notice-search.md) |
| 장학금 검색 및 조회 | 한국장학재단·전국 대학교·재단·기업 장학 공고를 검색해 금액·자격·지원구간·링크를 정리하고 KST 기준 현재 날짜 마감 상태와 조건별 필터링까지 제공 | 불필요 | [장학금 검색 및 조회 가이드](docs/features/korean-scholarship-search.md) |
| 생활쓰레기 배출정보 조회 | 시군구 기준 생활쓰레기·음식물·재활용 배출요일·시간·장소·관리부서 확인 | 불필요 | [생활쓰레기 배출정보 조회 가이드](docs/features/household-waste-info.md) |
| 학교 급식 식단 조회 | 교육청·학교명으로 NEIS 학교 검색·급식 식단 조회 | 불필요 | [학교 급식 식단 조회 가이드](docs/features/k-schoollunch-menu.md) |
@ -110,6 +111,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [한국 법령 검색 가이드](docs/features/korean-law-search.md)
- [한국 개인정보처리방침·이용약관 자동 생성 가이드](docs/features/korean-privacy-terms.md)
- [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md)
- [LH 청약 공고문 조회 가이드](docs/features/lh-notice-search.md)
- [장학금 검색 및 조회 가이드](docs/features/korean-scholarship-search.md)
- [생활쓰레기 배출정보 조회 가이드](docs/features/household-waste-info.md)
- [학교 급식 식단 조회 가이드](docs/features/k-schoollunch-menu.md)

View file

@ -0,0 +1,137 @@
# LH 청약 공고문 조회 가이드
한국토지주택공사(LH)가 `apply.lh.or.kr` 로 공고하는 임대주택·분양주택·주거복지(신혼희망타운)·토지·상가 공고를 공공데이터포털 공식 LH 공고 Open API(`http://apis.data.go.kr/B552555/lhLeaseNoticeInfo1/...`)로 조회한다. 프록시 서버가 `serviceKey` 주입, 캐시, 율속을 맡는다.
## 이 기능으로 할 수 있는 일
- 공고중·접수중·접수마감 등 상태별 LH 공고 목록 조회
- 지역(시/도), 공고 유형(영구임대·행복주택·전세임대·국민임대·매입임대·분양주택·신혼희망타운 등) 필터링
- 공고명 키워드 검색 (예: "행복주택", "든든주택", "청년", "신혼희망")
- 공고 게시일·접수 마감일 구간 필터
- 공고 상세(주택형별 공급 정보, 공식 링크) 확인
## 가장 중요한 규칙
기본 경로는 `https://k-skill-proxy.nomadamas.org/v1/lh-notice/...` 이다. 사용자는 **공공데이터포털 ServiceKey 를 준비할 필요가 없다**. upstream key(`DATA_GO_KR_API_KEY`)는 프록시 서버에서만 주입한다.
마감 여부는 **KST(Asia/Seoul)** 기준으로 판정한다. host local time 을 쓰지 않는다.
본 스킬은 LH 공고 전용이다. SH(서울), GH(경기), iH(인천) 등 지방 주택공사 공고는 포함되지 않는다. 사용자가 그런 공고를 찾으면 본 스킬 범위가 아님을 분명히 말한다.
## 먼저 필요한 것
- 인터넷 연결
- `curl` 또는 HTTP 호출이 가능한 도구
- (프록시 운영자 전용) `DATA_GO_KR_API_KEY` 환경변수
## 지원 엔드포인트
| Route | 설명 |
| --- | --- |
| `GET /v1/lh-notice/search` | 공고 목록 조회. 필터 전부 선택사항. |
| `GET /v1/lh-notice/detail` | 특정 공고 상세 + 주택형별 공급 정보. `panId`/`ccrCnntSysDsCd`/`splInfTpCd` 모두 필수. |
### `/v1/lh-notice/search` 파라미터
| 파라미터 | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `panSs` / `status` | string | (없음) | `공고중`, `접수중`, `접수마감`, `당첨자발표`, `추정공고` |
| `uppAisTpCd` / `category` | string | (없음) | `01`=토지, `05`=분양주택, `06`=임대주택, `13`=주거복지, `22`=상가 |
| `aisTpCd` | digits | (없음) | 세부 분류 코드. 예: `09`=영구임대, `10`=행복주택, `17`=전세임대 |
| `cnpCdNm` / `region` | string | (없음) | 지역명(시/도). 예: `서울특별시`, `부산광역시`, `전국` |
| `panNm` / `q` / `keyword` | string | (없음) | 공고명 부분 검색 |
| `panNtStDt` / `startDate` | date | (없음) | 공고 게시일 시작. `YYYY-MM-DD` / `YYYYMMDD` / `YYYY.MM.DD` |
| `clsgDt` / `endDate` | date | (없음) | 접수 마감일 종료 |
| `page` | int | 1 | 페이지 (최대 1000) |
| `pageSize` / `PG_SZ` / `numOfRows` / `limit` | int | 50 | 페이지당 건수 (최대 1000) |
### `/v1/lh-notice/detail` 파라미터
| 파라미터 | 타입 | 필수 | 설명 |
| --- | --- | --- | --- |
| `panId` | digits | ✅ | 공고 ID. 목록 응답의 `pan_id` 와 동일 |
| `ccrCnntSysDsCd` | digits | ✅ | 연계시스템 구분 코드. 목록 응답의 `ccr_cnnt_sys_ds_cd` |
| `splInfTpCd` | digits | ✅ | 공급 정보 유형 코드. 목록 응답의 `spl_inf_tp_cd` |
## 기본 흐름
1. 사용자 요청의 지역·공고 유형·상태를 추출한다.
2. 필터를 붙여 `/v1/lh-notice/search` 를 호출한다.
3. KST 오늘 날짜로 마감 여부(D-day)를 표시한다.
4. 공고 상위 3-5건(공고명 / 지역 / 공고일 / 마감일 / 상태 / 링크)을 요약한다.
5. 필요하면 `/v1/lh-notice/detail` 로 주택형별 공급 정보를 추가 조회한다.
## CLI 예시
공고중인 부산 영구임대 공고 목록:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/lh-notice/search' \
--data-urlencode 'panSs=공고중' \
--data-urlencode 'uppAisTpCd=06' \
--data-urlencode 'cnpCdNm=부산광역시' \
--data-urlencode 'pageSize=20'
```
키워드 검색 (행복주택, 접수중만):
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/lh-notice/search' \
--data-urlencode 'q=행복주택' \
--data-urlencode 'status=접수중'
```
공고 상세:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/lh-notice/detail' \
--data-urlencode 'panId=2015122300019828' \
--data-urlencode 'ccrCnntSysDsCd=03' \
--data-urlencode 'splInfTpCd=051'
```
## 응답 예시 (목록)
```json
{
"items": [
{
"pan_id": "2015122300019828",
"pan_nm": "2026년 상반기 부산광역시 영구임대주택 예비입주자 모집 공고",
"upp_ais_tp_cd": "06",
"ais_tp_cd": "09",
"ais_tp_cd_nm": "영구임대",
"cnp_cd_nm": "부산광역시",
"pan_ss": "공고중",
"pan_dt": "2026-04-21",
"clsg_dt": "2026-05-06",
"spl_inf_tp_cd": "051",
"ccr_cnnt_sys_ds_cd": "03",
"detail_url": "https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancInfo.do?panId=2015122300019828&..."
}
],
"summary": { "page": 1, "page_size": 20, "returned_count": 1, "total_count": 1 },
"query": { "pan_ss": "공고중", "upp_ais_tp_cd": "06", "cnp_cd_nm": "부산광역시" },
"proxy": { "name": "k-skill-proxy", "cache": { "hit": false, "ttl_ms": 300000 } }
}
```
## 실패 모드
- `400 bad_request`: `panSs` 값이 허용되지 않거나, 날짜 포맷이 잘못된 경우 등. 메시지를 그대로 사용자에게 노출한다.
- `503 upstream_not_configured`: 프록시 서버에 `DATA_GO_KR_API_KEY` 가 없는 경우. 운영자가 키를 등록해야 한다.
- `502 upstream_error`: 공공데이터포털 서버 오류 또는 XML 에러 envelope. `upstream_code` 에 원본 코드가 들어간다 (예: `30` = 등록되지 않은 서비스키).
- `502 upstream_invalid_payload`: 응답이 JSON 이 아닌 경우 (보통 HTML 장애 페이지).
## Done when
- 공식 LH 공고 목록을 조회했다.
- 마감 여부를 KST 기준으로 판정해 표시했다.
- 각 결과에 공고 상세 링크(`detail_url`)를 포함했다.
- 필요한 경우 `panId`/`ccrCnntSysDsCd`/`splInfTpCd` 를 제공해 상세 조회로 이어갈 수 있도록 안내했다.
## 출처/참고
- LH 청약플러스 공고 목록: `https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancList.do?mi=1026`
- 공공데이터포털 LH 임대공고문 정보 API: `https://www.data.go.kr/data/15058530/openapi.do`
- 레퍼런스 오픈소스: `heereal/Bunyang_MoeumZip` (Next.js 기반 LH/SH 공고 모음집 샘플 구현)

View file

@ -20,6 +20,7 @@
- 한국 법령 검색 스킬 출시
- 한국 개인정보처리방침·이용약관 스킬 출시 (kimlawtech/korean-privacy-terms Apache-2.0 업스트림 기반 thin wrapper)
- 한국 부동산 실거래가 조회 스킬 출시
- LH 청약 공고문 조회 스킬 출시
- 의약품 안전 체크 스킬 출시
- 식품 안전 체크 스킬 출시
- 장학금 검색 및 조회 스킬 출시

View file

@ -56,6 +56,11 @@
- MOLIT 단독/다가구 매매 API: https://apis.data.go.kr/1613000/RTMSDataSvcSHTrade/getRTMSDataSvcSHTrade
- MOLIT 단독/다가구 전월세 API: https://apis.data.go.kr/1613000/RTMSDataSvcSHRent/getRTMSDataSvcSHRent
- MOLIT 상업업무용 매매 API: https://apis.data.go.kr/1613000/RTMSDataSvcNrgTrade/getRTMSDataSvcNrgTrade
- LH 청약플러스 공고 목록: https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancList.do?mi=1026
- 공공데이터포털 한국토지주택공사 임대공고문 정보 API: https://www.data.go.kr/data/15058530/openapi.do
- LH 임대공고문 목록 endpoint: http://apis.data.go.kr/B552555/lhLeaseNoticeInfo1/lhLeaseNoticeInfo1
- LH 임대공고문 상세(공급정보) endpoint: http://apis.data.go.kr/B552555/lhLeaseNoticeDtlInfo1/getLeaseNoticeDtlInfo1
- LH 청약 샘플 reference 구현(heereal/Bunyang_MoeumZip): https://github.com/heereal/Bunyang_MoeumZip
- beopmang: https://api.beopmang.org
- `silver-flight-group/kakaocli`: https://github.com/silver-flight-group/kakaocli
- KakaoTalk Mac 설치 참고(`mas`): https://velog.io/@bonjugi/%EB%A7%A5%EB%B6%81-M1%EC%97%90-homebrew%EB%A1%9C-node-vscode-%EC%B9%B4%EC%B9%B4%EC%98%A4%ED%86%A1-%EC%84%A4%EC%B9%98%ED%95%98%EA%B8%B0

216
lh-notice-search/SKILL.md Normal file
View file

@ -0,0 +1,216 @@
---
name: lh-notice-search
description: Search official LH 청약 (Korea Land & Housing Corporation lease/subscription) 공고 lists through k-skill-proxy. Use when a user asks about 청년/행복/영구/국민/매입/전세임대, 분양주택, 신혼희망타운 공고 marketed on apply.lh.or.kr.
license: MIT
metadata:
category: real-estate
locale: ko-KR
phase: v1
---
# LH 청약 공고문 조회
## What this skill does
한국토지주택공사(LH)가 `apply.lh.or.kr` 로 공고하는 **임대주택·분양주택·주거복지(신혼희망타운 등)·토지·상가 공고**를 공공데이터포털(`data.go.kr`)의 공식 LH 공고 Open API로 조회한다. 요청은 `k-skill-proxy``/v1/lh-notice/*` 라우트로 보내고, 결과는 공고 목록·공고 상세(주택형별 공급 정보)로 정리한다.
본 스킬은 사회초년생, 청년, 신혼부부처럼 **LH 공고 존재 자체를 모르는 사용자**가 공고 마감 전에 공고문을 빠르게 찾을 수 있도록 돕는다.
## When to use
- "LH 영구임대 공고 지금 뭐 올라와 있어?"
- "신혼희망타운 공고 요즘 나온 거 정리해줘"
- "부산광역시 LH 임대주택 공고중인 것 보여줘"
- "전세임대 공고 중 마감 임박한 거 찾아줘"
- "공고번호 2015122300019828 상세 정보 보여줘"
- "행복주택 청년 모집 공고 요약해줘"
## When not to use
- 개별 사용자의 자격 심사, 당첨 예측, 가점 계산
- 청약 신청 자동화/자동 제출 (본 스킬은 read-only 조회다)
- 청약통장·주택도시기금 계좌 업무 (해당 범위는 LH 공고와 별개다)
- SH(서울주택도시공사)·GH(경기주택도시공사)·iH(인천도시공사) 전용 공고 (본 스킬은 LH 공고만 다룬다)
## Inputs
- `panSs` (또는 `status`): 공고 상태. `공고중`, `접수중`, `접수마감`, `당첨자발표`, `추정공고` 중 하나. 비우면 전체.
- `uppAisTpCd` (또는 `category`): 주택 대분류. `01`(토지), `05`(분양주택), `06`(임대주택), `13`(주거복지·신혼희망타운), `22`(상가).
- `aisTpCd`: 세부 분류 코드 (숫자). 예: `09`=영구임대, `10`=행복주택, `17`=전세임대.
- `cnpCdNm` (또는 `region`): 지역명. 예: `서울특별시`, `부산광역시`, `전국`.
- `panNm` (또는 `q`, `keyword`): 공고명 부분 검색 키워드. 예: `행복주택`, `청년`, `든든주택`.
- `panNtStDt` (또는 `startDate`): 공고 게시일 시작. YYYY-MM-DD / YYYYMMDD / YYYY.MM.DD 모두 허용.
- `clsgDt` (또는 `endDate`): 접수 마감일 종료. 날짜 포맷 동일.
- `page` (기본 1, 최대 1000), `pageSize` (기본 50, 최대 1000).
상세 조회 (`/v1/lh-notice/detail`) 는 `panId`, `ccrCnntSysDsCd`, `splInfTpCd` 세 값 모두 필수다. 이 값은 목록 응답의 `pan_id`, `ccr_cnnt_sys_ds_cd`, `spl_inf_tp_cd` 를 그대로 쓰면 된다.
## Prerequisites
- 인터넷 연결
- `curl` (또는 동일한 HTTP 호출이 가능한 도구)
사용자에게 필요한 시크릿은 없다. 공공데이터포털 `DATA_GO_KR_API_KEY``k-skill-proxy` 서버 쪽에만 둔다.
## Default path
`KSKILL_PROXY_BASE_URL` 환경변수가 있으면 그 값, 없으면 기본 `https://k-skill-proxy.nomadamas.org` 를 사용한다.
```bash
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
BASE="${BASE%/}"
```
## Supported endpoints
### 공고 목록 조회
```
GET /v1/lh-notice/search
```
필터는 선택사항이다. 필터 없이 호출하면 최근 공고를 상태 무관하게 최대 50건 돌려준다. 마감 임박한 공고만 보고 싶다면 `panSs=공고중``clsgDt` 로 구간을 좁힌다.
### 공고 상세 조회
```
GET /v1/lh-notice/detail?panId={공고ID}&ccrCnntSysDsCd={연계시스템코드}&splInfTpCd={공급정보유형코드}
```
상세 응답은 `notice`(공고 요약) + `supply_infos`(주택형/필지/상가호 별 공급 정보 배열) 를 돌려준다.
## Example requests
### 목록 — 부산 영구임대 공고중
```bash
curl -fsS --get "${BASE}/v1/lh-notice/search" \
--data-urlencode 'panSs=공고중' \
--data-urlencode 'uppAisTpCd=06' \
--data-urlencode 'cnpCdNm=부산광역시' \
--data-urlencode 'pageSize=20'
```
### 목록 — 키워드 "행복주택" 으로 접수중
```bash
curl -fsS --get "${BASE}/v1/lh-notice/search" \
--data-urlencode 'q=행복주택' \
--data-urlencode 'status=접수중'
```
### 상세 — 특정 공고
```bash
curl -fsS --get "${BASE}/v1/lh-notice/detail" \
--data-urlencode 'panId=2015122300019828' \
--data-urlencode 'ccrCnntSysDsCd=03' \
--data-urlencode 'splInfTpCd=051'
```
## Response shape
### 목록 응답
```json
{
"items": [
{
"pan_id": "2015122300019828",
"pan_nm": "2026년 상반기 부산광역시 영구임대주택 예비입주자 모집 공고",
"upp_ais_tp_cd": "06",
"ais_tp_cd": "09",
"ais_tp_cd_nm": "영구임대",
"cnp_cd_nm": "부산광역시",
"pan_ss": "공고중",
"pan_dt": "2026-04-21",
"clsg_dt": "2026-05-06",
"rcrit_pblanc_dt": null,
"spl_inf_tp_cd": "051",
"ccr_cnnt_sys_ds_cd": "03",
"detail_url": "https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancInfo.do?panId=2015122300019828&..."
}
],
"summary": {
"page": 1,
"page_size": 20,
"returned_count": 1,
"total_count": 1
},
"query": {
"pan_ss": "공고중",
"upp_ais_tp_cd": "06",
"cnp_cd_nm": "부산광역시"
},
"proxy": {
"name": "k-skill-proxy",
"cache": { "hit": false, "ttl_ms": 300000 }
}
}
```
### 상세 응답
```json
{
"notice": {
"pan_id": "2015122300019828",
"pan_nm": "2026년 상반기 부산광역시 영구임대주택 예비입주자 모집 공고",
"ais_tp_cd_nm": "영구임대",
"...": "목록 응답과 동일한 필드"
},
"supply_infos": [
{ "HOUSE_TY": "영구임대 29㎡", "SPL_CNT": "120" },
{ "HOUSE_TY": "영구임대 39㎡", "SPL_CNT": "80" }
],
"query": {
"pan_id": "2015122300019828",
"ccr_cnnt_sys_ds_cd": "03",
"spl_inf_tp_cd": "051"
},
"proxy": { "...": "..." }
}
```
## Response policy
- 공식 LH 공고(`apply.lh.or.kr`) 정보만 사용한다. 커뮤니티 요약, 블로그 후기, 사설 부동산 정보는 섞지 않는다.
- **마감 여부는 KST 기준 현재 날짜와 `clsg_dt` 를 비교해 판정**한다. 오늘 = 마감일이면 "오늘 마감"으로 표기한다.
- 상세 응답의 `detail_url` 을 항상 함께 보여 준다. 사용자는 공고문 원본으로 바로 접근할 수 있어야 한다.
- 공고번호(`pan_id`) 를 숨기지 말고 요약에 포함한다. 이후 상세 조회에 그대로 쓴다.
- **본 스킬은 SH·GH·iH 공고를 포함하지 않는다.** 사용자가 서울시·경기도·인천시 공사 공고를 찾으면 본 스킬로는 못 찾는다는 점을 분명히 말한다.
## Keep the answer compact
사용자에게 돌려줄 때는 이렇게 압축한다.
- 필터 요약: 지역 + 공고 유형 + 상태
- 결과 건수 (`summary.total_count``returned_count`)
- 상위 3-5건 대표 공고: 공고명, 지역, 공고일, 마감일, 상태, 링크
- 마감 임박(D-3 이하) 공고는 별도로 강조
- 상세 조회 제안: "공고번호 X 상세 보고 싶으면 `lh-notice/detail` 로 조회"
## Failure modes
- 필터 값이 잘못되면 `400 bad_request` 가 돌아온다. 오류 메시지를 그대로 노출해 사용자가 교정하게 한다.
- 프록시 서버에 `DATA_GO_KR_API_KEY` 가 없으면 `503 upstream_not_configured` 가 돌아온다.
- upstream(공공데이터포털) 이 일시 장애이면 `502 upstream_error` + `upstream_code` 를 돌려준다. 재시도는 캐시되지 않으므로 바로 다시 호출해도 된다.
- upstream 이 XML 에러 envelope(`OpenAPI_ServiceResponse`) 를 돌려주면 `502 upstream_error` + `upstream_code`(예: `30` = 등록되지 않은 서비스키) 로 변환한다.
- 응답이 JSON 이 아니면 `502 upstream_invalid_payload` 로 내려간다.
## Done when
- 사용자의 지역·공고 유형·상태 의도에 맞춰 적어도 한 번 `/v1/lh-notice/search` 를 호출했다.
- 결과에 공고명, 지역, 공고일/마감일, 상태, 공식 링크가 모두 포함되어 있다.
- 마감 여부를 KST 기준으로 판정해 표기했다.
- 필요하면 상세 조회로 이어가거나 사용자가 스스로 상세를 조회할 수 있도록 `panId`/`ccrCnntSysDsCd`/`splInfTpCd` 를 함께 안내했다.
## Notes
- 공식 LH 청약플러스 포털: `https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancList.do?mi=1026`
- 공공데이터포털 카탈로그: `https://www.data.go.kr/data/15058530/openapi.do` (LH 임대공고문 정보)
- upstream `panSs` 값은 한국어로 정확히 맞춰서 보낸다. 영문/공백 변형은 받지 않는다.
- 대분류/세부분류 코드 매핑 참고:
- `06` 임대주택: `09` 영구임대, `10` 행복주택, `17` 전세임대, `08` 국민임대, `26` 매입임대 등
- `13` 주거복지: `17` 전세임대, 신혼희망타운 등
- `05` 분양주택, `01` 토지, `22` 상가

View file

@ -24,6 +24,8 @@
- `GET /v1/data4library/book-detail` — 도서관 정보나루 도서 상세 조회(`DATA4LIBRARY_AUTH_KEY`)
- `GET /v1/data4library/libraries-by-book` — 도서 소장 도서관 조회(`DATA4LIBRARY_AUTH_KEY`)
- `GET /v1/data4library/book-exists` — 도서관별 도서 소장여부(`DATA4LIBRARY_AUTH_KEY`)
- `GET /v1/lh-notice/search` — LH 청약 공고 목록(`DATA_GO_KR_API_KEY`)
- `GET /v1/lh-notice/detail` — LH 청약 공고 상세(`DATA_GO_KR_API_KEY`)
## 환경변수
@ -41,7 +43,7 @@
- `KSKILL_PROXY_CACHE_TTL_MS` — 기본 `300000`
- `KSKILL_PROXY_RATE_LIMIT_WINDOW_MS` — 기본 `60000`
- `KSKILL_PROXY_RATE_LIMIT_MAX` — 기본 `60`
- `DATA_GO_KR_API_KEY` - 공공데이터포털 에서 쓰이는 API 인증키 (`household-waste`, `parking-lots`, `real-estate`, `mfds-drug-safety`, `mfds-food-safety`)
- `DATA_GO_KR_API_KEY` - 공공데이터포털 에서 쓰이는 API 인증키 (`household-waste`, `parking-lots`, `real-estate`, `mfds-drug-safety`, `mfds-food-safety`, `lh-notice`). 각 서비스는 공공데이터포털에서 별도 "활용신청" 승인이 필요하다. 키를 발급받은 뒤에는 [LH 임대공고문 정보](https://www.data.go.kr/data/15058530/openapi.do) 페이지에서도 활용신청을 눌러 동일 키를 활성화해야 `lh-notice` 라우트가 성공한다. 미활성 상태에서는 upstream이 HTTP 403 Forbidden을 돌려주고 proxy는 `upstream_error`로 변환한다.
기본 정책은 **무료 API 공개 프록시 = 무인증** 이다. 대신 endpoint scope 를 좁게 유지하고, cache + rate limit 으로 남용을 늦춘다.
@ -171,6 +173,25 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/korean-stock/search' \
--data-urlencode 'bas_dd=20260408'
```
LH 청약 공고 목록 예시 (`DATA_GO_KR_API_KEY` 필요):
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/lh-notice/search' \
--data-urlencode 'panSs=공고중' \
--data-urlencode 'uppAisTpCd=06' \
--data-urlencode 'cnpCdNm=부산광역시' \
--data-urlencode 'pageSize=20'
```
LH 청약 공고 상세:
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/lh-notice/detail' \
--data-urlencode 'panId=2015122300019828' \
--data-urlencode 'ccrCnntSysDsCd=03' \
--data-urlencode 'splInfTpCd=051'
```
프록시는 내부적으로 `waterlevel/info.json` 으로 관측소를 해석하고, `waterlevel/list/10M/{WLOBSCD}.json` 으로 최신 수위/유량을 조회합니다. 한국 주식 route는 KRX Open API에 `AUTH_KEY` 헤더를 서버 쪽에서만 주입합니다.

View file

@ -9,7 +9,7 @@
"node": ">=18"
},
"scripts": {
"lint": "node --check src/airkorea.js && node --check src/bluer.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-shopping.js && node --check src/parking-lots.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/molit.test.js && node --check test/naver-shopping.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
"lint": "node --check src/airkorea.js && node --check src/bluer.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/lh-notice.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-shopping.js && node --check src/parking-lots.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/lh-notice.test.js && node --check test/molit.test.js && node --check test/naver-shopping.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
"test": "node --test"
},
"dependencies": {

View file

@ -0,0 +1,531 @@
// LH 청약 (Korea Land & Housing Corporation lease/subscription notice) API wrapper.
// Proxies the official data.go.kr LH Lease Notice endpoint so the user never has to
// manage ServiceKey. The upstream base is:
// http://apis.data.go.kr/B552555/lhLeaseNoticeInfo1/lhLeaseNoticeInfo1 (list)
// http://apis.data.go.kr/B552555/lhLeaseNoticeDtlInfo1/getLeaseNoticeDtlInfo1 (detail)
//
// The upstream responds with a JSON array whose shape is:
// [
// { "CMN": { "CODE": "SUCCESS", "ERR_MSG": "", "TOTAL_CNT": 123 } },
// { "dsList": [ { PAN_ID, PAN_NM, ... }, ... ] }
// ]
// Error payloads can also arrive as XML from the common data.go.kr error path, e.g.
// unregistered ServiceKey, so both JSON and XML fault paths must be handled.
const LH_UPSTREAM_BASE_URL = "http://apis.data.go.kr/B552555";
const LH_LIST_PATH = "lhLeaseNoticeInfo1/lhLeaseNoticeInfo1";
const LH_DETAIL_PATH = "lhLeaseNoticeDtlInfo1/getLeaseNoticeDtlInfo1";
const LH_DEFAULT_LIST_URL = `${LH_UPSTREAM_BASE_URL}/${LH_LIST_PATH}`;
const LH_DEFAULT_DETAIL_URL = `${LH_UPSTREAM_BASE_URL}/${LH_DETAIL_PATH}`;
// Valid `PAN_SS` filter values (공고 상태). Users pass these in Korean exactly the
// way the upstream catalog documents them. We do NOT guess variants — if the user
// omits this filter, we pass nothing and the upstream returns every status.
const VALID_PAN_SS_VALUES = new Set([
"공고중",
"접수중",
"접수마감",
"당첨자발표",
"추정공고"
]);
// Known `UPP_AIS_TP_CD` categories (주택 대분류).
// 06 = 임대주택, 05 = 분양주택, 13 = 주거복지 (신혼희망타운 포함), 01 = 토지, 22 = 상가
const VALID_UPP_AIS_TP_CD_VALUES = new Set([
"01", "05", "06", "13", "22"
]);
function trimOrNull(value) {
if (value === undefined || value === null) {
return null;
}
const trimmed = String(value).trim();
return trimmed ? trimmed : null;
}
function parseBoundedInt(value, { defaultValue, min, max, label }) {
if (value === undefined || value === null || String(value).trim() === "") {
return defaultValue;
}
const text = String(value).trim();
if (!/^\d+$/.test(text)) {
throw new Error(`Provide valid ${label}.`);
}
const parsed = Number.parseInt(text, 10);
if (parsed < min) {
return min;
}
if (parsed > max) {
return max;
}
return parsed;
}
function normalizeDateString(value, label) {
const trimmed = trimOrNull(value);
if (trimmed === null) {
return null;
}
// Accept YYYY-MM-DD, YYYY.MM.DD, YYYYMMDD; normalize to YYYY-MM-DD per LH upstream docs.
const digits = trimmed.replace(/[.\-\/]/g, "");
if (!/^\d{8}$/.test(digits)) {
throw new Error(`Provide ${label} as YYYY-MM-DD (8 digits).`);
}
return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6, 8)}`;
}
function normalizeLhNoticeSearchQuery(query) {
const normalized = {};
const panSs = trimOrNull(query.panSs ?? query.PAN_SS ?? query.status);
if (panSs) {
if (!VALID_PAN_SS_VALUES.has(panSs)) {
throw new Error(
`panSs must be one of: ${Array.from(VALID_PAN_SS_VALUES).join(", ")}.`
);
}
normalized.panSs = panSs;
}
const uppAisTpCd = trimOrNull(
query.uppAisTpCd ?? query.UPP_AIS_TP_CD ?? query.category
);
if (uppAisTpCd) {
if (!VALID_UPP_AIS_TP_CD_VALUES.has(uppAisTpCd)) {
throw new Error(
`uppAisTpCd must be one of: ${Array.from(VALID_UPP_AIS_TP_CD_VALUES).join(", ")}.`
);
}
normalized.uppAisTpCd = uppAisTpCd;
}
const aisTpCd = trimOrNull(query.aisTpCd ?? query.AIS_TP_CD);
if (aisTpCd) {
if (!/^[0-9]{1,4}$/.test(aisTpCd)) {
throw new Error("aisTpCd must be digits only (1-4 chars).");
}
normalized.aisTpCd = aisTpCd;
}
const cnpCdNm = trimOrNull(
query.cnpCdNm ?? query.CNP_CD_NM ?? query.region ?? query.regionName
);
if (cnpCdNm) {
normalized.cnpCdNm = cnpCdNm;
}
const panNm = trimOrNull(
query.panNm ?? query.PAN_NM ?? query.q ?? query.query ?? query.keyword
);
if (panNm) {
normalized.panNm = panNm;
}
const panNtStDt = normalizeDateString(
query.panNtStDt ?? query.PAN_NT_ST_DT ?? query.startDate,
"panNtStDt"
);
if (panNtStDt) {
normalized.panNtStDt = panNtStDt;
}
const clsgDt = normalizeDateString(
query.clsgDt ?? query.CLSG_DT ?? query.endDate,
"clsgDt"
);
if (clsgDt) {
normalized.clsgDt = clsgDt;
}
normalized.page = parseBoundedInt(query.page ?? query.PAGE ?? query.pageNo, {
defaultValue: 1,
min: 1,
max: 1000,
label: "page"
});
normalized.pageSize = parseBoundedInt(
query.pageSize ?? query.PG_SZ ?? query.numOfRows ?? query.limit,
{
defaultValue: 50,
min: 1,
max: 1000,
label: "pageSize"
}
);
return normalized;
}
function normalizeLhNoticeDetailQuery(query) {
const panId = trimOrNull(query.panId ?? query.PAN_ID);
if (!panId) {
throw new Error("Provide panId.");
}
if (!/^[0-9]{4,20}$/.test(panId)) {
throw new Error("panId must be digits only.");
}
const ccrCnntSysDsCd = trimOrNull(
query.ccrCnntSysDsCd ?? query.CCR_CNNT_SYS_DS_CD ?? query.systemCode
);
if (!ccrCnntSysDsCd) {
throw new Error("Provide ccrCnntSysDsCd.");
}
if (!/^[0-9]{1,4}$/.test(ccrCnntSysDsCd)) {
throw new Error("ccrCnntSysDsCd must be digits only (1-4 chars).");
}
const splInfTpCd = trimOrNull(
query.splInfTpCd ?? query.SPL_INF_TP_CD ?? query.supplyTypeCode
);
if (!splInfTpCd) {
throw new Error("Provide splInfTpCd.");
}
if (!/^[0-9]{1,6}$/.test(splInfTpCd)) {
throw new Error("splInfTpCd must be digits only (1-6 chars).");
}
return { panId, ccrCnntSysDsCd, splInfTpCd };
}
function normalizeNoticeItem(raw) {
if (!raw || typeof raw !== "object") {
return null;
}
// Upstream delivers keys in UPPER_SNAKE_CASE. Map to lower snake_case so the
// proxy response matches other k-skill-proxy JSON shapes.
const panId = trimOrNull(raw.PAN_ID ?? raw.panId);
const panNm = trimOrNull(raw.PAN_NM ?? raw.panNm);
if (!panId && !panNm) {
return null;
}
const uppAisTpCd = trimOrNull(raw.UPP_AIS_TP_CD ?? raw.uppAisTpCd);
const aisTpCd = trimOrNull(raw.AIS_TP_CD ?? raw.aisTpCd);
const aisTpCdNm = trimOrNull(raw.AIS_TP_CD_NM ?? raw.aisTpCdNm);
const cnpCdNm = trimOrNull(raw.CNP_CD_NM ?? raw.cnpCdNm);
const panSs = trimOrNull(raw.PAN_SS ?? raw.panSs);
const panDt = trimOrNull(raw.PAN_DT ?? raw.panDt ?? raw.PAN_NT_ST_DT ?? raw.panNtStDt);
const clsgDt = trimOrNull(raw.CLSG_DT ?? raw.clsgDt ?? raw.PAN_NT_ED_DT ?? raw.panNtEdDt);
const rcritPblancDt = trimOrNull(raw.RCRIT_PBLANC_DT ?? raw.rcritPblancDt);
const dtlUrl = trimOrNull(raw.DTL_URL ?? raw.dtlUrl);
const splInfTpCd = trimOrNull(raw.SPL_INF_TP_CD ?? raw.splInfTpCd);
const ccrCnntSysDsCd = trimOrNull(raw.CCR_CNNT_SYS_DS_CD ?? raw.ccrCnntSysDsCd);
return {
pan_id: panId,
pan_nm: panNm,
upp_ais_tp_cd: uppAisTpCd,
ais_tp_cd: aisTpCd,
ais_tp_cd_nm: aisTpCdNm,
cnp_cd_nm: cnpCdNm,
pan_ss: panSs,
pan_dt: panDt,
clsg_dt: clsgDt,
rcrit_pblanc_dt: rcritPblancDt,
spl_inf_tp_cd: splInfTpCd,
ccr_cnnt_sys_ds_cd: ccrCnntSysDsCd,
detail_url: dtlUrl,
raw
};
}
// data.go.kr services return XML on common auth/parameter errors even when JSON
// is requested. Pull resultCode/resultMsg and surface them as a structured error.
function parseXmlErrorEnvelope(xmlText) {
if (typeof xmlText !== "string" || xmlText.length === 0) {
return null;
}
if (!/<OpenAPI_ServiceResponse>|<response[\s>]|<returnAuthMsg>|<errMsg>/i.test(xmlText)) {
return null;
}
const codeMatch = xmlText.match(/<resultCode>([^<]*)<\/resultCode>/i)
|| xmlText.match(/<returnReasonCode>([^<]*)<\/returnReasonCode>/i);
const msgMatch = xmlText.match(/<resultMsg>([^<]*)<\/resultMsg>/i)
|| xmlText.match(/<returnAuthMsg>([^<]*)<\/returnAuthMsg>/i)
|| xmlText.match(/<errMsg>([^<]*)<\/errMsg>/i);
const code = codeMatch ? codeMatch[1].trim() : null;
const message = msgMatch ? msgMatch[1].trim() : "LH upstream returned an error envelope.";
if (!code && !msgMatch) {
return null;
}
return {
code: code || "unknown",
message
};
}
function buildError({ message, statusCode, code, upstreamCode }) {
const error = new Error(message);
error.statusCode = statusCode;
error.code = code;
if (upstreamCode) {
error.upstreamCode = upstreamCode;
}
return error;
}
// Parse the LH JSON envelope into { totalCount, items, raw }.
// The upstream envelope shape is a 2-element array:
// [ { CMN: { CODE, ERR_MSG, TOTAL_CNT } }, { dsList: [...] } ]
// but the proxy is resilient to the more common data.go.kr shape
// `{ response: { body: { items: [...], totalCount } } }` as well.
function extractNoticeEnvelope(parsed) {
if (Array.isArray(parsed)) {
const head = parsed[0] || {};
const body = parsed[1] || {};
const cmn = head.CMN || head.cmn || {};
const code = trimOrNull(cmn.CODE ?? cmn.code);
const errMsg = trimOrNull(cmn.ERR_MSG ?? cmn.errMsg);
if (code && code !== "SUCCESS" && code !== "0" && code !== "00" && code !== "000") {
throw buildError({
message: errMsg || `LH upstream rejected the request (${code}).`,
statusCode: 502,
code: "upstream_error",
upstreamCode: code
});
}
const totalCount = Number.isFinite(Number(cmn.TOTAL_CNT ?? cmn.totalCount))
? Number(cmn.TOTAL_CNT ?? cmn.totalCount)
: null;
const rawItems = Array.isArray(body.dsList)
? body.dsList
: Array.isArray(body.DS_LIST)
? body.DS_LIST
: [];
return { totalCount, items: rawItems };
}
if (parsed && typeof parsed === "object") {
const response = parsed.response || parsed;
const header = response.header || {};
const headerCode = trimOrNull(header.resultCode);
if (headerCode && !["00", "000", "0"].includes(headerCode)) {
throw buildError({
message: trimOrNull(header.resultMsg) || `LH upstream rejected the request (${headerCode}).`,
statusCode: 502,
code: "upstream_error",
upstreamCode: headerCode
});
}
const body = response.body || {};
const rawItems = Array.isArray(body.items)
? body.items
: Array.isArray(body.item)
? body.item
: Array.isArray(body.items?.item)
? body.items.item
: [];
const totalCount = Number.isFinite(Number(body.totalCount))
? Number(body.totalCount)
: null;
return { totalCount, items: rawItems };
}
return { totalCount: null, items: [] };
}
function buildNoticeListResponseBody(envelope, { page, pageSize, filters }) {
const items = [];
for (const raw of envelope.items) {
const normalized = normalizeNoticeItem(raw);
if (normalized) {
items.push(normalized);
}
}
return {
items,
summary: {
page,
page_size: pageSize,
returned_count: items.length,
total_count: envelope.totalCount
},
query: filters
};
}
function buildNoticeDetailResponseBody(envelope, filters) {
// Detail envelope can return multiple supply rows (per 주택형). Keep them all as
// `supply_infos` plus a normalized `notice` summary using the first row.
const supplyInfos = envelope.items.slice();
const first = supplyInfos[0] || {};
const notice = normalizeNoticeItem(first) || {
pan_id: filters.panId,
pan_nm: null,
upp_ais_tp_cd: null,
ais_tp_cd: null,
ais_tp_cd_nm: null,
cnp_cd_nm: null,
pan_ss: null,
pan_dt: null,
clsg_dt: null,
rcrit_pblanc_dt: null,
spl_inf_tp_cd: filters.splInfTpCd,
ccr_cnnt_sys_ds_cd: filters.ccrCnntSysDsCd,
detail_url: null,
raw: first
};
return {
notice,
supply_infos: supplyInfos,
query: filters
};
}
async function fetchLhUpstream({ url, fetchImpl = global.fetch, timeoutMs = 20000 }) {
let response;
try {
response = await fetchImpl(url.toString(), {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(timeoutMs)
});
} catch (err) {
throw buildError({
message: `LH upstream request failed: ${err.message}`,
statusCode: 502,
code: "upstream_fetch_failed"
});
}
const text = await response.text();
if (!response.ok) {
const xmlError = parseXmlErrorEnvelope(text);
if (xmlError) {
throw buildError({
message: xmlError.message,
statusCode: response.status === 401 ? 503 : 502,
code: response.status === 401 ? "upstream_not_authorized" : "upstream_error",
upstreamCode: xmlError.code
});
}
throw buildError({
message: `LH upstream responded with HTTP ${response.status}: ${text.slice(0, 200)}`,
statusCode: response.status === 401 ? 503 : 502,
code: response.status === 401 ? "upstream_not_authorized" : "upstream_error"
});
}
// data.go.kr sometimes returns 200 + XML envelope carrying the real error.
const xmlError = parseXmlErrorEnvelope(text);
if (xmlError) {
throw buildError({
message: xmlError.message,
statusCode: 502,
code: "upstream_error",
upstreamCode: xmlError.code
});
}
let parsed;
try {
parsed = JSON.parse(text);
} catch (err) {
throw buildError({
message: `LH upstream returned non-JSON payload (first 200 chars): ${text.slice(0, 200)}`,
statusCode: 502,
code: "upstream_invalid_payload"
});
}
return parsed;
}
async function fetchLhNoticeList({ serviceKey, filters, fetchImpl = global.fetch }) {
const url = new URL(LH_DEFAULT_LIST_URL);
url.searchParams.set("serviceKey", serviceKey);
// Upstream uses SCREAMING_SNAKE_CASE param names (PG_SZ, PAGE, PAN_SS, etc.).
url.searchParams.set("PG_SZ", String(filters.pageSize));
url.searchParams.set("PAGE", String(filters.page));
if (filters.panSs) {
url.searchParams.set("PAN_SS", filters.panSs);
}
if (filters.uppAisTpCd) {
url.searchParams.set("UPP_AIS_TP_CD", filters.uppAisTpCd);
}
if (filters.aisTpCd) {
url.searchParams.set("AIS_TP_CD", filters.aisTpCd);
}
if (filters.cnpCdNm) {
url.searchParams.set("CNP_CD_NM", filters.cnpCdNm);
}
if (filters.panNm) {
url.searchParams.set("PAN_NM", filters.panNm);
}
if (filters.panNtStDt) {
url.searchParams.set("PAN_NT_ST_DT", filters.panNtStDt);
}
if (filters.clsgDt) {
url.searchParams.set("CLSG_DT", filters.clsgDt);
}
const parsed = await fetchLhUpstream({ url, fetchImpl });
const envelope = extractNoticeEnvelope(parsed);
return buildNoticeListResponseBody(envelope, {
page: filters.page,
pageSize: filters.pageSize,
filters: {
pan_ss: filters.panSs || null,
upp_ais_tp_cd: filters.uppAisTpCd || null,
ais_tp_cd: filters.aisTpCd || null,
cnp_cd_nm: filters.cnpCdNm || null,
pan_nm: filters.panNm || null,
pan_nt_st_dt: filters.panNtStDt || null,
clsg_dt: filters.clsgDt || null
}
});
}
async function fetchLhNoticeDetail({ serviceKey, filters, fetchImpl = global.fetch }) {
const url = new URL(LH_DEFAULT_DETAIL_URL);
url.searchParams.set("serviceKey", serviceKey);
url.searchParams.set("PAN_ID", filters.panId);
url.searchParams.set("CCR_CNNT_SYS_DS_CD", filters.ccrCnntSysDsCd);
url.searchParams.set("SPL_INF_TP_CD", filters.splInfTpCd);
const parsed = await fetchLhUpstream({ url, fetchImpl });
const envelope = extractNoticeEnvelope(parsed);
return buildNoticeDetailResponseBody(envelope, {
pan_id: filters.panId,
ccr_cnnt_sys_ds_cd: filters.ccrCnntSysDsCd,
spl_inf_tp_cd: filters.splInfTpCd
});
}
module.exports = {
LH_UPSTREAM_BASE_URL,
LH_LIST_PATH,
LH_DETAIL_PATH,
LH_DEFAULT_LIST_URL,
LH_DEFAULT_DETAIL_URL,
VALID_PAN_SS_VALUES,
VALID_UPP_AIS_TP_CD_VALUES,
normalizeLhNoticeSearchQuery,
normalizeLhNoticeDetailQuery,
normalizeNoticeItem,
parseXmlErrorEnvelope,
extractNoticeEnvelope,
buildNoticeListResponseBody,
buildNoticeDetailResponseBody,
fetchLhNoticeList,
fetchLhNoticeDetail
};

View file

@ -13,6 +13,12 @@ const {
normalizeMfdsDrugLookupQuery,
normalizeMfdsFoodSafetyQuery
} = require("./mfds");
const {
fetchLhNoticeDetail,
fetchLhNoticeList,
normalizeLhNoticeDetailQuery,
normalizeLhNoticeSearchQuery
} = require("./lh-notice");
const { fetchTransactions, VALID_ASSET_TYPES, VALID_DEAL_TYPES } = require("./molit");
const { fetchNaverShoppingSearch, normalizeNaverShoppingSearchQuery } = require("./naver-shopping");
const { fetchNearbyParkingLots } = require("./parking-lots");
@ -1281,6 +1287,7 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
hrfcoConfigured: Boolean(config.hrfcoApiKey),
opinetConfigured: Boolean(config.opinetApiKey),
molitConfigured: Boolean(config.molitApiKey),
lhNoticeConfigured: Boolean(config.molitApiKey),
data4libraryConfigured: Boolean(config.data4libraryAuthKey),
foodsafetyKoreaConfigured: Boolean(config.foodsafetyKoreaApiKey),
neisSchoolMealConfigured: Boolean(config.keduInfoKey),
@ -2091,6 +2098,168 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
return payload;
});
app.get("/v1/lh-notice/search", async (request, reply) => {
let normalized;
try {
normalized = normalizeLhNoticeSearchQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "lh-notice-search",
...normalized
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
if (!config.molitApiKey) {
reply.code(503);
return {
error: "upstream_not_configured",
message: "DATA_GO_KR_API_KEY is not configured on the proxy server.",
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
}
let body;
try {
body = await fetchLhNoticeList({
serviceKey: config.molitApiKey,
filters: normalized
});
} catch (error) {
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
return {
error: error.code || "proxy_error",
message: error.message,
upstream_code: error.upstreamCode || undefined,
proxy: {
name: config.proxyName,
cache: { hit: false, ttl_ms: config.cacheTtlMs }
}
};
}
const payload = {
...body,
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
}
};
cache.set(cacheKey, payload, config.cacheTtlMs);
return payload;
});
app.get("/v1/lh-notice/detail", async (request, reply) => {
let normalized;
try {
normalized = normalizeLhNoticeDetailQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "lh-notice-detail",
...normalized
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
if (!config.molitApiKey) {
reply.code(503);
return {
error: "upstream_not_configured",
message: "DATA_GO_KR_API_KEY is not configured on the proxy server.",
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
}
let body;
try {
body = await fetchLhNoticeDetail({
serviceKey: config.molitApiKey,
filters: normalized
});
} catch (error) {
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
return {
error: error.code || "proxy_error",
message: error.message,
upstream_code: error.upstreamCode || undefined,
proxy: {
name: config.proxyName,
cache: { hit: false, ttl_ms: config.cacheTtlMs }
}
};
}
const payload = {
...body,
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
}
};
cache.set(cacheKey, payload, config.cacheTtlMs);
return payload;
});
app.get("/v1/mfds/drug-safety/lookup", async (request, reply) => {
let normalized;
@ -3207,6 +3376,8 @@ module.exports = {
normalizeKmaForecastQuery,
normalizeKoreanStockLookupQuery,
normalizeKoreanStockSearchQuery,
normalizeLhNoticeDetailQuery,
normalizeLhNoticeSearchQuery,
normalizeOpinetAroundQuery,
normalizeOpinetDetailQuery,
normalizeNeisSchoolMealQuery,

View file

@ -0,0 +1,530 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
LH_DEFAULT_LIST_URL,
LH_DEFAULT_DETAIL_URL,
VALID_PAN_SS_VALUES,
VALID_UPP_AIS_TP_CD_VALUES,
normalizeLhNoticeSearchQuery,
normalizeLhNoticeDetailQuery,
normalizeNoticeItem,
parseXmlErrorEnvelope,
extractNoticeEnvelope,
buildNoticeListResponseBody,
buildNoticeDetailResponseBody,
fetchLhNoticeList,
fetchLhNoticeDetail
} = require("../src/lh-notice");
const SAMPLE_LIST_PAYLOAD = [
{
CMN: {
CODE: "SUCCESS",
ERR_MSG: "",
TOTAL_CNT: 3
}
},
{
dsList: [
{
PAN_ID: "2015122300019828",
PAN_NM: "2026년 상반기 부산광역시 영구임대주택 예비입주자 모집 공고",
UPP_AIS_TP_CD: "06",
AIS_TP_CD: "09",
AIS_TP_CD_NM: "영구임대",
CNP_CD_NM: "부산광역시",
PAN_SS: "공고중",
PAN_DT: "2026-04-21",
CLSG_DT: "2026-05-06",
SPL_INF_TP_CD: "051",
CCR_CNNT_SYS_DS_CD: "03",
DTL_URL:
"https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancInfo.do?ccrCnntSysDsCd=03&panId=2015122300019828&aisTpCd=09&uppAisTpCd=06&mi=1026"
},
{
PAN_ID: "2015122300019816",
PAN_NM: "2026 전세임대형 든든주택 1, 2순위 입주자 모집 공고",
UPP_AIS_TP_CD: "13",
AIS_TP_CD: "17",
AIS_TP_CD_NM: "전세임대",
CNP_CD_NM: "전국",
PAN_SS: "공고중",
PAN_DT: "2026-04-21",
CLSG_DT: "2026-05-08",
SPL_INF_TP_CD: "072",
CCR_CNNT_SYS_DS_CD: "03",
DTL_URL: "https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancInfo.do?panId=2015122300019816"
},
{
PAN_ID: "",
PAN_NM: ""
}
]
}
];
const SAMPLE_DETAIL_PAYLOAD = [
{
CMN: {
CODE: "SUCCESS",
ERR_MSG: "",
TOTAL_CNT: 2
}
},
{
dsList: [
{
PAN_ID: "2015122300019828",
PAN_NM: "2026년 상반기 부산광역시 영구임대주택 예비입주자 모집 공고",
UPP_AIS_TP_CD: "06",
AIS_TP_CD: "09",
AIS_TP_CD_NM: "영구임대",
CNP_CD_NM: "부산광역시",
PAN_SS: "공고중",
PAN_DT: "2026-04-21",
CLSG_DT: "2026-05-06",
SPL_INF_TP_CD: "051",
CCR_CNNT_SYS_DS_CD: "03",
HOUSE_TY: "영구임대 29㎡",
SPL_CNT: "120"
},
{
PAN_ID: "2015122300019828",
HOUSE_TY: "영구임대 39㎡",
SPL_CNT: "80"
}
]
}
];
const XML_AUTH_ERROR = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<OpenAPI_ServiceResponse>
<cmmMsgHeader>
<errMsg>SERVICE ERROR</errMsg>
<returnAuthMsg>SERVICE_KEY_IS_NOT_REGISTERED_ERROR</returnAuthMsg>
<returnReasonCode>30</returnReasonCode>
</cmmMsgHeader>
</OpenAPI_ServiceResponse>`;
const STANDARD_JSON_ENVELOPE = {
response: {
header: { resultCode: "00", resultMsg: "NORMAL SERVICE." },
body: {
totalCount: 1,
items: [
{
PAN_ID: "9999",
PAN_NM: "standard envelope sample"
}
]
}
}
};
test("VALID_PAN_SS_VALUES contains the five documented statuses", () => {
for (const status of ["공고중", "접수중", "접수마감", "당첨자발표", "추정공고"]) {
assert.equal(VALID_PAN_SS_VALUES.has(status), true, `missing ${status}`);
}
});
test("VALID_UPP_AIS_TP_CD_VALUES covers the main LH categories", () => {
for (const code of ["01", "05", "06", "13", "22"]) {
assert.equal(VALID_UPP_AIS_TP_CD_VALUES.has(code), true, `missing ${code}`);
}
});
test("normalizeLhNoticeSearchQuery accepts empty input and applies defaults", () => {
const normalized = normalizeLhNoticeSearchQuery({});
assert.equal(normalized.page, 1);
assert.equal(normalized.pageSize, 50);
assert.equal(normalized.panSs, undefined);
assert.equal(normalized.uppAisTpCd, undefined);
assert.equal(normalized.cnpCdNm, undefined);
});
test("normalizeLhNoticeSearchQuery accepts camelCase, snake_case, and short aliases", () => {
const normalized = normalizeLhNoticeSearchQuery({
status: "공고중",
category: "06",
region: "서울특별시",
keyword: "행복주택",
startDate: "2026-01-01",
endDate: "2026.12.31",
page: "2",
limit: "10"
});
assert.equal(normalized.panSs, "공고중");
assert.equal(normalized.uppAisTpCd, "06");
assert.equal(normalized.cnpCdNm, "서울특별시");
assert.equal(normalized.panNm, "행복주택");
assert.equal(normalized.panNtStDt, "2026-01-01");
assert.equal(normalized.clsgDt, "2026-12-31");
assert.equal(normalized.page, 2);
assert.equal(normalized.pageSize, 10);
});
test("normalizeLhNoticeSearchQuery rejects invalid panSs", () => {
assert.throws(
() => normalizeLhNoticeSearchQuery({ status: "대기중" }),
/panSs must be one of/
);
});
test("normalizeLhNoticeSearchQuery rejects invalid uppAisTpCd", () => {
assert.throws(
() => normalizeLhNoticeSearchQuery({ category: "77" }),
/uppAisTpCd must be one of/
);
});
test("normalizeLhNoticeSearchQuery rejects invalid aisTpCd (letters)", () => {
assert.throws(
() => normalizeLhNoticeSearchQuery({ aisTpCd: "abc" }),
/aisTpCd must be digits/
);
});
test("normalizeLhNoticeSearchQuery clamps page/pageSize to documented bounds", () => {
const high = normalizeLhNoticeSearchQuery({ page: "99999", pageSize: "99999" });
assert.equal(high.page, 1000);
assert.equal(high.pageSize, 1000);
const low = normalizeLhNoticeSearchQuery({ page: "0", pageSize: "0" });
assert.equal(low.page, 1);
assert.equal(low.pageSize, 1);
});
test("normalizeLhNoticeSearchQuery accepts YYYYMMDD and YYYY.MM.DD", () => {
const a = normalizeLhNoticeSearchQuery({ startDate: "20260101" });
assert.equal(a.panNtStDt, "2026-01-01");
const b = normalizeLhNoticeSearchQuery({ startDate: "2026.01.02" });
assert.equal(b.panNtStDt, "2026-01-02");
});
test("normalizeLhNoticeSearchQuery rejects malformed dates", () => {
assert.throws(
() => normalizeLhNoticeSearchQuery({ startDate: "2026" }),
/panNtStDt as YYYY-MM-DD/
);
});
test("normalizeLhNoticeDetailQuery requires all three codes", () => {
assert.throws(
() => normalizeLhNoticeDetailQuery({}),
/Provide panId/
);
assert.throws(
() => normalizeLhNoticeDetailQuery({ panId: "2015122300019828" }),
/Provide ccrCnntSysDsCd/
);
assert.throws(
() =>
normalizeLhNoticeDetailQuery({
panId: "2015122300019828",
ccrCnntSysDsCd: "03"
}),
/Provide splInfTpCd/
);
});
test("normalizeLhNoticeDetailQuery accepts the production payload", () => {
const normalized = normalizeLhNoticeDetailQuery({
panId: "2015122300019828",
ccrCnntSysDsCd: "03",
splInfTpCd: "051"
});
assert.deepEqual(normalized, {
panId: "2015122300019828",
ccrCnntSysDsCd: "03",
splInfTpCd: "051"
});
});
test("normalizeLhNoticeDetailQuery rejects non-numeric panId", () => {
assert.throws(
() =>
normalizeLhNoticeDetailQuery({
panId: "abc123",
ccrCnntSysDsCd: "03",
splInfTpCd: "051"
}),
/panId must be digits/
);
});
test("normalizeNoticeItem maps upstream SCREAMING_SNAKE_CASE keys to snake_case", () => {
const result = normalizeNoticeItem({
PAN_ID: "2015122300019828",
PAN_NM: "샘플 공고",
UPP_AIS_TP_CD: "06",
AIS_TP_CD: "09",
AIS_TP_CD_NM: "영구임대",
CNP_CD_NM: "부산광역시",
PAN_SS: "공고중",
PAN_DT: "2026-04-21",
CLSG_DT: "2026-05-06",
SPL_INF_TP_CD: "051",
CCR_CNNT_SYS_DS_CD: "03",
DTL_URL: "https://apply.lh.or.kr/..."
});
assert.equal(result.pan_id, "2015122300019828");
assert.equal(result.pan_nm, "샘플 공고");
assert.equal(result.upp_ais_tp_cd, "06");
assert.equal(result.ais_tp_cd_nm, "영구임대");
assert.equal(result.cnp_cd_nm, "부산광역시");
assert.equal(result.pan_ss, "공고중");
assert.equal(result.pan_dt, "2026-04-21");
assert.equal(result.clsg_dt, "2026-05-06");
assert.equal(result.spl_inf_tp_cd, "051");
assert.equal(result.ccr_cnnt_sys_ds_cd, "03");
assert.equal(result.detail_url, "https://apply.lh.or.kr/...");
assert.ok(result.raw, "raw pass-through must be kept");
});
test("normalizeNoticeItem drops rows with no panId AND no panNm", () => {
assert.equal(normalizeNoticeItem({}), null);
assert.equal(normalizeNoticeItem({ PAN_ID: "" }), null);
assert.equal(normalizeNoticeItem({ PAN_NM: " " }), null);
});
test("normalizeNoticeItem keeps rows that have panId only", () => {
const result = normalizeNoticeItem({ PAN_ID: "123" });
assert.equal(result.pan_id, "123");
assert.equal(result.pan_nm, null);
});
test("parseXmlErrorEnvelope pulls code+msg from the OpenAPI_ServiceResponse form", () => {
const err = parseXmlErrorEnvelope(XML_AUTH_ERROR);
assert.ok(err);
assert.equal(err.code, "30");
assert.match(err.message, /SERVICE_KEY/);
});
test("parseXmlErrorEnvelope returns null for non-XML payloads", () => {
assert.equal(parseXmlErrorEnvelope(""), null);
assert.equal(parseXmlErrorEnvelope("Unauthorized"), null);
assert.equal(parseXmlErrorEnvelope('{"a":1}'), null);
});
test("extractNoticeEnvelope handles the LH [CMN,dsList] array envelope", () => {
const envelope = extractNoticeEnvelope(SAMPLE_LIST_PAYLOAD);
assert.equal(envelope.totalCount, 3);
assert.equal(envelope.items.length, 3);
assert.equal(envelope.items[0].PAN_ID, "2015122300019828");
});
test("extractNoticeEnvelope throws an error for CMN.CODE != SUCCESS", () => {
const payload = [
{ CMN: { CODE: "FAIL", ERR_MSG: "Service key invalid", TOTAL_CNT: 0 } },
{ dsList: [] }
];
assert.throws(() => extractNoticeEnvelope(payload), /Service key invalid/);
});
test("extractNoticeEnvelope also handles the standard data.go.kr response/body shape", () => {
const envelope = extractNoticeEnvelope(STANDARD_JSON_ENVELOPE);
assert.equal(envelope.totalCount, 1);
assert.equal(envelope.items.length, 1);
assert.equal(envelope.items[0].PAN_ID, "9999");
});
test("extractNoticeEnvelope returns empty list for unknown shapes", () => {
assert.deepEqual(extractNoticeEnvelope(null), { totalCount: null, items: [] });
assert.deepEqual(extractNoticeEnvelope({ foo: "bar" }), { totalCount: null, items: [] });
});
test("buildNoticeListResponseBody produces the proxy-facing JSON shape", () => {
const envelope = extractNoticeEnvelope(SAMPLE_LIST_PAYLOAD);
const body = buildNoticeListResponseBody(envelope, {
page: 1,
pageSize: 50,
filters: { pan_ss: "공고중" }
});
assert.equal(body.items.length, 2, "empty row must be skipped");
assert.equal(body.summary.page, 1);
assert.equal(body.summary.page_size, 50);
assert.equal(body.summary.returned_count, 2);
assert.equal(body.summary.total_count, 3);
assert.equal(body.query.pan_ss, "공고중");
assert.equal(body.items[0].pan_id, "2015122300019828");
});
test("buildNoticeDetailResponseBody returns notice + supply_infos", () => {
const envelope = extractNoticeEnvelope(SAMPLE_DETAIL_PAYLOAD);
const body = buildNoticeDetailResponseBody(envelope, {
panId: "2015122300019828",
ccrCnntSysDsCd: "03",
splInfTpCd: "051"
});
assert.equal(body.notice.pan_id, "2015122300019828");
assert.equal(body.notice.ais_tp_cd_nm, "영구임대");
assert.equal(body.supply_infos.length, 2);
assert.equal(body.supply_infos[0].HOUSE_TY, "영구임대 29㎡");
assert.equal(body.query.panId, "2015122300019828");
});
test("fetchLhNoticeList builds the expected data.go.kr URL and returns parsed items", async () => {
const calls = [];
const mockFetch = async (url) => {
calls.push(url);
return {
ok: true,
status: 200,
headers: { get: () => "application/json;charset=UTF-8" },
text: async () => JSON.stringify(SAMPLE_LIST_PAYLOAD)
};
};
const body = await fetchLhNoticeList({
serviceKey: "test-key",
filters: {
page: 2,
pageSize: 25,
panSs: "공고중",
uppAisTpCd: "06",
aisTpCd: "09",
cnpCdNm: "부산광역시",
panNm: "영구임대",
panNtStDt: "2026-04-01",
clsgDt: "2026-05-31"
},
fetchImpl: mockFetch
});
assert.equal(calls.length, 1);
const requested = calls[0];
assert.ok(requested.startsWith(LH_DEFAULT_LIST_URL), `URL must hit list endpoint: ${requested}`);
assert.match(requested, /PG_SZ=25/);
assert.match(requested, /PAGE=2/);
assert.match(requested, /PAN_SS=%EA%B3%B5%EA%B3%A0%EC%A4%91/);
assert.match(requested, /UPP_AIS_TP_CD=06/);
assert.match(requested, /AIS_TP_CD=09/);
assert.match(requested, /CNP_CD_NM=%EB%B6%80%EC%82%B0/);
assert.match(requested, /PAN_NT_ST_DT=2026-04-01/);
assert.match(requested, /CLSG_DT=2026-05-31/);
assert.match(requested, /serviceKey=test-key/);
assert.equal(body.items.length, 2);
assert.equal(body.summary.page, 2);
assert.equal(body.summary.page_size, 25);
assert.equal(body.summary.total_count, 3);
});
test("fetchLhNoticeList surfaces upstream 401 as upstream_not_authorized (statusCode 503)", async () => {
const mockFetch = async () => ({
ok: false,
status: 401,
headers: { get: () => "text/plain" },
text: async () => "Unauthorized"
});
await assert.rejects(
fetchLhNoticeList({
serviceKey: "bad",
filters: { page: 1, pageSize: 50 },
fetchImpl: mockFetch
}),
(err) => {
assert.equal(err.code, "upstream_not_authorized");
assert.equal(err.statusCode, 503);
return true;
}
);
});
test("fetchLhNoticeList surfaces XML SERVICE_KEY error envelopes with upstreamCode", async () => {
const mockFetch = async () => ({
ok: true,
status: 200,
headers: { get: () => "text/xml" },
text: async () => XML_AUTH_ERROR
});
await assert.rejects(
fetchLhNoticeList({
serviceKey: "bad",
filters: { page: 1, pageSize: 50 },
fetchImpl: mockFetch
}),
(err) => {
assert.equal(err.code, "upstream_error");
assert.equal(err.statusCode, 502);
assert.equal(err.upstreamCode, "30");
assert.match(err.message, /SERVICE_KEY/);
return true;
}
);
});
test("fetchLhNoticeList surfaces non-JSON payloads as upstream_invalid_payload", async () => {
const mockFetch = async () => ({
ok: true,
status: 200,
headers: { get: () => "text/html" },
text: async () => "<html>not json</html>"
});
await assert.rejects(
fetchLhNoticeList({
serviceKey: "x",
filters: { page: 1, pageSize: 50 },
fetchImpl: mockFetch
}),
(err) => {
assert.equal(err.code, "upstream_invalid_payload");
assert.equal(err.statusCode, 502);
return true;
}
);
});
test("fetchLhNoticeList surfaces fetch failures as upstream_fetch_failed", async () => {
const mockFetch = async () => {
throw new Error("socket hang up");
};
await assert.rejects(
fetchLhNoticeList({
serviceKey: "x",
filters: { page: 1, pageSize: 50 },
fetchImpl: mockFetch
}),
(err) => {
assert.equal(err.code, "upstream_fetch_failed");
assert.equal(err.statusCode, 502);
return true;
}
);
});
test("fetchLhNoticeDetail calls the detail endpoint with PAN_ID + SPL_INF_TP_CD + CCR_CNNT_SYS_DS_CD", async () => {
const calls = [];
const mockFetch = async (url) => {
calls.push(url);
return {
ok: true,
status: 200,
headers: { get: () => "application/json" },
text: async () => JSON.stringify(SAMPLE_DETAIL_PAYLOAD)
};
};
const body = await fetchLhNoticeDetail({
serviceKey: "test-key",
filters: { panId: "2015122300019828", ccrCnntSysDsCd: "03", splInfTpCd: "051" },
fetchImpl: mockFetch
});
assert.equal(calls.length, 1);
const requested = calls[0];
assert.ok(requested.startsWith(LH_DEFAULT_DETAIL_URL), `URL must hit detail endpoint: ${requested}`);
assert.match(requested, /PAN_ID=2015122300019828/);
assert.match(requested, /CCR_CNNT_SYS_DS_CD=03/);
assert.match(requested, /SPL_INF_TP_CD=051/);
assert.equal(body.notice.pan_id, "2015122300019828");
assert.equal(body.supply_infos.length, 2);
});

View file

@ -3602,3 +3602,219 @@ test("parking lot search endpoint reports missing Data.go.kr key", async (t) =>
assert.equal(response.json().error, "upstream_not_configured");
assert.match(response.json().message, /DATA_GO_KR_API_KEY/);
});
test("lh-notice search endpoint stays public and caches normalized queries", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url, options = {}) => {
fetchCalls.push(String(url));
return new Response(
JSON.stringify([
{ CMN: { CODE: "SUCCESS", ERR_MSG: "", TOTAL_CNT: 2 } },
{
dsList: [
{
PAN_ID: "2015122300019828",
PAN_NM: "2026년 상반기 부산광역시 영구임대주택 예비입주자 모집 공고",
UPP_AIS_TP_CD: "06",
AIS_TP_CD: "09",
AIS_TP_CD_NM: "영구임대",
CNP_CD_NM: "부산광역시",
PAN_SS: "공고중",
PAN_DT: "2026-04-21",
CLSG_DT: "2026-05-06",
SPL_INF_TP_CD: "051",
CCR_CNNT_SYS_DS_CD: "03",
DTL_URL: "https://apply.lh.or.kr/detail?panId=2015122300019828"
},
{
PAN_ID: "2015122300019816",
PAN_NM: "2026 전세임대형 든든주택 모집 공고",
UPP_AIS_TP_CD: "13",
AIS_TP_CD: "17",
AIS_TP_CD_NM: "전세임대",
CNP_CD_NM: "전국",
PAN_SS: "공고중"
}
]
}
]),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({
env: {
DATA_GO_KR_API_KEY: "data-go-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const first = await app.inject({
method: "GET",
url: "/v1/lh-notice/search?panSs=%EA%B3%B5%EA%B3%A0%EC%A4%91&limit=10"
});
const firstBody = first.json();
assert.equal(first.statusCode, 200);
assert.equal(firstBody.proxy.cache.hit, false);
assert.equal(firstBody.items.length, 2);
assert.equal(firstBody.items[0].pan_id, "2015122300019828");
assert.equal(firstBody.items[0].ais_tp_cd_nm, "영구임대");
assert.equal(firstBody.summary.returned_count, 2);
assert.equal(firstBody.summary.total_count, 2);
assert.equal(firstBody.query.pan_ss, "공고중");
assert.equal(fetchCalls.length, 1);
assert.match(fetchCalls[0], /PG_SZ=10/);
assert.match(fetchCalls[0], /PAGE=1/);
assert.match(fetchCalls[0], /serviceKey=data-go-key/);
const second = await app.inject({
method: "GET",
url: "/v1/lh-notice/search?status=%EA%B3%B5%EA%B3%A0%EC%A4%91&pageSize=10"
});
const secondBody = second.json();
assert.equal(second.statusCode, 200);
assert.equal(secondBody.proxy.cache.hit, true, "alias-normalized second call must reuse cache");
assert.equal(fetchCalls.length, 1, "cache hit must not hit upstream again");
});
test("lh-notice search returns 400 for unsupported panSs", async (t) => {
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => { await app.close(); });
const response = await app.inject({
method: "GET",
url: "/v1/lh-notice/search?panSs=%EB%8C%80%EA%B8%B0%EC%A4%91"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
assert.match(response.json().message, /panSs must be one of/);
});
test("lh-notice search returns 503 when DATA_GO_KR_API_KEY is missing", async (t) => {
const app = buildServer();
t.after(async () => { await app.close(); });
const response = await app.inject({
method: "GET",
url: "/v1/lh-notice/search"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
assert.match(response.json().message, /DATA_GO_KR_API_KEY/);
});
test("lh-notice search does not cache upstream XML auth errors so retries self-heal", async (t) => {
const originalFetch = global.fetch;
const xmlError = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<OpenAPI_ServiceResponse>
<cmmMsgHeader>
<errMsg>SERVICE ERROR</errMsg>
<returnAuthMsg>SERVICE_KEY_IS_NOT_REGISTERED_ERROR</returnAuthMsg>
<returnReasonCode>30</returnReasonCode>
</cmmMsgHeader>
</OpenAPI_ServiceResponse>`;
let mode = "fail";
const successPayload = [
{ CMN: { CODE: "SUCCESS", ERR_MSG: "", TOTAL_CNT: 1 } },
{ dsList: [{ PAN_ID: "111", PAN_NM: "recovered notice" }] }
];
global.fetch = async () => {
if (mode === "fail") {
return new Response(xmlError, { status: 200, headers: { "content-type": "text/xml" } });
}
return new Response(JSON.stringify(successPayload), {
status: 200,
headers: { "content-type": "application/json" }
});
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => { global.fetch = originalFetch; await app.close(); });
const first = await app.inject({ method: "GET", url: "/v1/lh-notice/search" });
assert.equal(first.statusCode, 502);
assert.equal(first.json().error, "upstream_error");
assert.equal(first.json().upstream_code, "30");
mode = "ok";
const second = await app.inject({ method: "GET", url: "/v1/lh-notice/search" });
assert.equal(second.statusCode, 200);
assert.equal(second.json().proxy.cache.hit, false, "failure must not have been cached");
assert.equal(second.json().items[0].pan_id, "111");
});
test("lh-notice detail endpoint requires all three codes and caches successful lookups", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
fetchCalls.push(String(url));
return new Response(
JSON.stringify([
{ CMN: { CODE: "SUCCESS", ERR_MSG: "", TOTAL_CNT: 1 } },
{
dsList: [
{
PAN_ID: "2015122300019828",
PAN_NM: "영구임대 예비입주자 모집",
UPP_AIS_TP_CD: "06",
AIS_TP_CD_NM: "영구임대",
HOUSE_TY: "29㎡",
SPL_CNT: "120"
}
]
}
]),
{ status: 200, headers: { "content-type": "application/json" } }
);
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => { global.fetch = originalFetch; await app.close(); });
const missing = await app.inject({ method: "GET", url: "/v1/lh-notice/detail" });
assert.equal(missing.statusCode, 400);
assert.match(missing.json().message, /Provide panId/);
const first = await app.inject({
method: "GET",
url: "/v1/lh-notice/detail?panId=2015122300019828&ccrCnntSysDsCd=03&splInfTpCd=051"
});
assert.equal(first.statusCode, 200);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(first.json().notice.pan_id, "2015122300019828");
assert.equal(first.json().notice.ais_tp_cd_nm, "영구임대");
assert.equal(first.json().supply_infos.length, 1);
assert.equal(fetchCalls.length, 1);
assert.match(fetchCalls[0], /lhLeaseNoticeDtlInfo1\/getLeaseNoticeDtlInfo1/);
assert.match(fetchCalls[0], /PAN_ID=2015122300019828/);
assert.match(fetchCalls[0], /CCR_CNNT_SYS_DS_CD=03/);
assert.match(fetchCalls[0], /SPL_INF_TP_CD=051/);
const second = await app.inject({
method: "GET",
url: "/v1/lh-notice/detail?panId=2015122300019828&ccrCnntSysDsCd=03&splInfTpCd=051"
});
assert.equal(second.statusCode, 200);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(fetchCalls.length, 1, "cached detail must not retrigger upstream");
});
test("health endpoint reports lhNoticeConfigured when DATA_GO_KR_API_KEY is set", async (t) => {
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => { await app.close(); });
const response = await app.inject({ method: "GET", url: "/health" });
assert.equal(response.statusCode, 200);
assert.equal(response.json().upstreams.lhNoticeConfigured, true);
});