Add court-auction-notice-search skill and package (#167)

Implement Workflow A (매각공고 → 사건/물건 펼치기) and Workflow B
(사건번호 직조회) MVP for the official 대법원경매정보 site
courtauction.go.kr. The package exposes searchSaleNotices,
getSaleNoticeDetail, getCaseByCaseNumber, and getCourtCodes plus a
court-auction-notice-search CLI mirror. Direct HTTP transport is the
default with a Playwright fallback (rebrowser-playwright /
playwright-core, dynamic import) for blocked/5xx situations.

Anti-bot guardrails: minimum 2s + jitter between calls, 10-call
session budget, immediate BLOCKED throw on data.ipcheck === false, and
no automatic retry to avoid extending the site's IP block. Fixtures
were captured from live courtauction.go.kr endpoints during discovery
and live smoke tests verify each public API end-to-end.

Workflow C (자유 조건검색), Workflow D (일별/월별 캘린더), 매각물건
사진/PDF, and 동산 경매는 follow-up issues로 분리됨.
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-29 16:36:55 +09:00
commit d11c7d37bf
31 changed files with 2873 additions and 2 deletions

View file

@ -0,0 +1,5 @@
---
"court-auction-notice-search": minor
---
Add the initial `court-auction-notice-search` package and matching skill. Browses 대법원경매정보(`courtauction.go.kr`) 부동산 매각공고 by 매각기일·법원·기일/기간 입찰, expands each notice into 사건번호·용도·주소·감정평가액·최저매각가, and looks up an auction case directly by 법원+사건번호. Direct HTTP transport with optional Playwright fallback, conservative ≥2s throttle and 10-call session budget, and an immediate `BLOCKED` throw when the site returns `data.ipcheck === false`.

View file

@ -36,6 +36,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 한국 개인정보처리방침·이용약관 자동 생성 | `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) |
| 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) |
| 생활쓰레기 배출정보 조회 | `household-waste-info` | 시군구 기준 생활쓰레기·음식물·재활용 배출요일·시간·장소·관리부서 확인 | 불필요 | [생활쓰레기 배출정보 조회 가이드](docs/features/household-waste-info.md) |
| 학교 급식 식단 조회 | `k-schoollunch-menu` | 교육청·학교명으로 NEIS 학교 검색·급식 식단 조회 | 불필요 | [학교 급식 식단 조회 가이드](docs/features/k-schoollunch-menu.md) |
@ -123,6 +124,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [한국 사업자 장부 자동화 가이드](docs/features/korean-jangbu-for.md)
- [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md)
- [LH 청약 공고문 조회 가이드](docs/features/lh-notice-search.md)
- [법원 경매 부동산 매각공고 조회 가이드](docs/features/court-auction-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,173 @@
---
name: court-auction-notice-search
description: Browse 대법원경매정보(courtauction.go.kr) 부동산 매각공고 by 매각기일·법원·기일/기간 입찰, expand each notice into 사건번호·용도·주소·감정평가액·최저매각가, and look up a case directly by 법원+사건번호. Read-only, slow-by-design (~2s/call) to avoid IP blocks. Use when the user asks "오늘 어디서 부동산 경매가 열려?" "이 사건번호 정보 알려줘" or wants 매각공고 데이터를 에이전트가 다룰 수 있는 JSON으로.
license: MIT
metadata:
category: real-estate
locale: ko-KR
phase: v1
---
# Court Auction Notice Search
## What this skill does
대한민국 법원이 운영하는 공식 **법원경매정보** 사이트(`courtauction.go.kr`) 의 매각공고와 사건정보를 에이전트가 활용할 수 있는 JSON 형태로 변환해서 돌려준다.
- 공식 OPEN API가 없어 사이트 내부의 WebSquare JSON XHR endpoint를 그대로 호출한다.
- 1차 transport 는 직접 HTTP, 차단되거나 5xx 가 떨어질 때만 Playwright fallback 으로 전환한다 (`rebrowser-playwright` 또는 `playwright-core` 가 있을 때만).
- 사이트는 **IP 단위 봇 차단** 이 매우 공격적이다 (16회/30초 정도면 1시간 차단). 이 패키지는 호출 간 최소 2초 jitter, 세션당 호출 budget(기본 10회), `data.ipcheck === false` 즉시 throw 로 보수적으로 동작한다.
- **참고용 도구**다. 실제 입찰 전에는 반드시 법원 원문 매각공고를 다시 확인해야 한다.
## When to use
- "오늘/내일 어디서 부동산 경매 열려?"
- "서울중앙지방법원 2026-04-27 매각공고 보여줘"
- "기일입찰 vs 기간입찰만 나눠서 보여줘"
- "이 매각공고 안의 사건번호/용도/주소/감정평가액 다 보여줘"
- "사건번호 2024타경100001 진행 상황 알려줘"
- "법원사무소 코드 표 줘"
## When not to use
- 동산(자동차·중기) 경매 (이번 v1 범위 밖)
- 자유 조건검색(지역·용도·가격대·면적·유찰횟수) — Workflow C 별도 follow-up 이슈에서 다룬다
- 특정 매각기일 날짜의 모든 법원 일정을 한 번에 (Workflow D 별도 follow-up 이슈)
- 매각물건 사진(전경/개황/내부) URL 노출 (별도 follow-up 이슈)
- 매각물건명세서 / 현황조사서 / 감정평가서 PDF 다운로드 (별도 follow-up 이슈)
- 입찰서 자동 작성·자동 제출 (지원하지 않는다, 입찰은 반드시 법원에서 사람이 직접)
## Inputs
- `date` — 매각기일 (YYYY-MM-DD 또는 YYYYMMDD). 필수.
- `courtCode` — 법원사무소코드 (예: `B000210` = 서울중앙지방법원). 비우면 전체. `getCourtCodes()` 또는 `codes courts` 로 받아온다.
- `bidType``date` (= 기일입찰, code 000331) 또는 `period` (= 기간입찰, code 000332). 빈값이면 둘 다.
- `caseNumber` — 사건번호. `2024타경100001` 형식 권장. `2024-100001` 도 받아서 `2024타경100001` 로 정규화한다.
## Mandatory honest framing
이 스킬은 사용자에게 다음 사실을 항상 알려야 한다.
1. 데이터는 법원경매정보 사이트의 공개 정보를 그대로 옮긴 것이며 **실제 입찰 전에 법원 원문을 재확인**해야 한다.
2. 사이트는 자동화 호출에 매우 민감해서 **빠른 연속 조회 시 IP가 1시간 차단**될 수 있다. 차단되면 같은 IP에서는 약 1시간을 기다려야 한다.
3. 가격(감정평가액·최저매각가격)·매각기일·매각장소는 **공고 시점 기준** 이며 정정·취하·연기로 변경될 수 있다 (`correctionCount`, `cancellationCount` 필드를 참고).
4. 본 스킬은 **read-only**다. 입찰 자체는 자동화하지 않는다.
## Official surfaces
- 법원경매정보 메인: `https://www.courtauction.go.kr`
- 부동산매각공고 진입: `https://www.courtauction.go.kr/pgj/index.on?w2xPath=/pgj/ui/pgj100/PGJ143M01.xml&pgjId=143M01`
- 경매사건검색 진입: `https://www.courtauction.go.kr/pgj/index.on?w2xPath=/pgj/ui/pgj100/PGJ159M00.xml&pgjId=159M00`
- 직접 호출 endpoint (이 스킬이 사용하는 것):
- `POST /pgj/pgj143/selectRletDspslPbanc.on` — 매각공고 목록
- `POST /pgj/pgj143/selectRletDspslPbancDtl.on` — 매각공고 상세 (사건/물건 펼치기)
- `POST /pgj/pgj15A/selectAuctnCsSrchRslt.on` — 사건 단건 조회
- `POST /pgj/pgjComm/selectCortOfcCdLst.on` — 법원사무소코드 전체
## Workflow A — 매각공고 → 사건/물건 펼치기
1. 사용자에게 **매각기일(YYYY-MM-DD)** 과 (선택) 법원·입찰구분을 받는다.
2. `searchSaleNotices({ date, courtCode, bidType })` 호출 → 그 날·그 법원의 매각공고 카드 목록.
3. 사용자가 카드를 고르면 카드 객체(또는 `raw`)를 그대로 `getSaleNoticeDetail(notice)` 에 넘긴다.
4. 응답의 `items[]``caseNumber`, `usage`, `address`, `appraisedPrice`, `minimumSalePrice`, `remarks` 를 가진다 (이슈 본문이 명시한 4필드 모두 포함).
5. 가격은 원 단위 정수다. 사용자에게 보여줄 때는 한국식 천단위 콤마 + 억/만 단위 환산을 같이 제시한다.
## Workflow B — 사건번호 직접 조회
1. 사용자에게 **법원사무소코드** + **사건번호(2024타경100001)** 를 받는다.
2. `getCaseByCaseNumber({ courtCode, caseNumber })` 호출.
3. `found:false / status:204` 면 사건이 존재하지 않거나 비공개. 사건번호 형식·법원이 맞는지 사용자에게 다시 확인한다.
4. `found:true``caseInfo`(사건명·접수일·청구액·재판부·진행상태), `items[]`(매각목적물 — 주소/배당요구종기), `schedule[]`(매각기일별 최저가/감정가/결과), `claimDeadline`, `relatedCases`, `stakeholders` 가 채워진다.
## Throttling and call-budget rules
- 호출 간 최소 2초 (기본). 더 늘리려면 `--min-delay-ms 3000`.
- 기본 세션 budget 은 **10회**. 더 많은 조회가 필요하면 새 세션을 열거나 (`new CourtAuctionHttpClient`) `maxCallsPerSession` 을 명시적으로 늘린다.
- 차단(`data.ipcheck === false`)을 만나면 `BLOCKED` 에러를 즉시 throw 하고 멈춘다. 자동 retry 하지 않는다 (차단 연장 위험).
- 차단된 IP는 **약 1시간** 후 자연 복구된다. 그 사이에는 다른 IP/네트워크에서 작업하거나 사람이 브라우저로 사이트에 접속해서 차단 해제 화면을 거친다.
## Node.js example
```js
const {
searchSaleNotices,
getSaleNoticeDetail,
getCaseByCaseNumber,
getCourtCodes
} = require("court-auction-notice-search");
async function main() {
const courts = await getCourtCodes();
console.log(`법원사무소 ${courts.count}개 로드됨`);
const notices = await searchSaleNotices({
date: "2026-04-27",
courtCode: "B000210",
bidType: "date"
});
console.log(`서울중앙지방법원 매각공고 ${notices.count}건`);
if (notices.items.length > 0) {
const detail = await getSaleNoticeDetail(notices.items[0]);
for (const item of detail.items) {
console.log(
`${item.caseNumber} (${item.usage}) — 감정 ${item.appraisedPrice}원 / 최저 ${item.minimumSalePrice}원`
);
console.log(` 주소: ${item.address}`);
}
}
const caseInfo = await getCaseByCaseNumber({
courtCode: "B000210",
caseNumber: "2024타경100001"
});
if (caseInfo.found) {
console.log(`사건명: ${caseInfo.caseInfo.caseName}`);
console.log(`매각기일 횟수: ${caseInfo.schedule.length}`);
}
}
main().catch((error) => {
if (error.code === "BLOCKED") {
console.error("[BLOCKED] 사이트가 1시간 차단했습니다. 다른 IP에서 다시 시도하거나 1시간 뒤 재시도하세요.");
} else {
console.error(error);
}
process.exitCode = 1;
});
```
## CLI example
```bash
# 1. 법원사무소 코드표
court-auction-notice-search codes courts --pretty | head -40
# 2. 입찰구분 (정적 코드)
court-auction-notice-search codes bid-types --pretty
# 3. 매각공고 목록
court-auction-notice-search notices --date 2026-04-27 --court-code B000210 --bid-type date --pretty
# 4. 매각공고 상세 — list 응답의 row 의 raw 필드를 그대로 detail 호출에 사용한다.
# (CLI 단발 호출에서는 list -> detail 으로 결과를 파이프할 수 있도록 jq 등을 함께 사용)
# 5. 사건번호 직접 조회
court-auction-notice-search case --court-code B000210 --case-number "2024타경100001" --pretty
```
## Block / Error handling
- `error.code === "BLOCKED"``data.ipcheck === false`. 1시간 대기 후 다른 IP에서 재시도. 사용자에게 차단 사실과 대기 안내를 그대로 전달한다.
- `error.code === "BUDGET_EXCEEDED"` — 세션 budget 초과. 의도적인 안전장치다. 정말 필요하면 `--max-calls 20` 같이 늘리지만 차단 위험을 함께 안내한다.
- `error.code === "UPSTREAM_ERROR"` — 사이트가 일반적인 에러를 돌려준 경우. 세션 만료 또는 잘못된 jdbnCd 가 가장 흔한 원인. warmup 부터 다시.
- `error.code === "NETWORK_ERROR"` — 타임아웃/연결 실패.
- `error.code === "PLAYWRIGHT_UNAVAILABLE"` — Playwright fallback 을 명시적으로 쓰려는데 모듈이 깔려있지 않음. `npm i rebrowser-playwright` 또는 `npm i playwright-core` 로 해결.
## Done when
- 사용자에게 IP 차단 위험과 "참고용·실제 입찰 전 법원 원문 재확인" 고지를 했다.
- 매각공고를 펼쳐서 `caseNumber/usage/address/appraisedPrice/minimumSalePrice` 가 채워진 JSON을 돌려줬다.
- 사건번호로 직접 조회한 경우, `found:false` 일 때 사용자가 후속 조치를 알 수 있도록 안내했다.
- 차단 발생 시 자동 재시도하지 않고 즉시 멈췄다.
- 작업 후 호출 budget 이 남아있는지 사용자에게 알려서 추가 호출 여지를 명시했다.

View file

@ -0,0 +1,96 @@
# 법원 경매 부동산 매각공고 조회
대한민국 법원이 운영하는 공식 **법원경매정보** 사이트(`courtauction.go.kr`) 의 매각공고와 사건정보를 에이전트가 활용할 수 있는 JSON 형태로 변환해서 돌려준다.
> **참고용입니다.** 실제 입찰 전에는 반드시 해당 법원의 원문 매각공고와 매각물건명세서를 직접 확인하세요. 본 스킬은 read-only이며, 입찰서 자동 작성·자동 제출은 지원하지 않습니다.
## 무엇을 할 수 있나
- ✅ Workflow A — **매각공고 브라우징**: 매각기일·법원·기일/기간 입찰을 조건으로 매각공고 목록 → 그 공고 안의 사건번호·용도·주소·감정평가액·최저매각가격 펼치기
- ✅ Workflow B — **사건번호 직접 조회**: 법원사무소코드 + 사건번호(`2024타경100001`) → 사건정보·물건내역·매각기일별 이력·배당요구종기
- ✅ 법원사무소 코드(60+개) + 입찰구분 코드(기일입찰=`000331`, 기간입찰=`000332`) 변환
- ✅ 2-tier transport — direct HTTP 1차, Playwright fallback 옵션
- ✅ 안티봇 가드 — 호출 간 ≥2초 jitter, 세션당 호출 budget, `data.ipcheck === false` 즉시 `BLOCKED` throw
## 무엇을 할 수 없나 (별도 follow-up 이슈)
- ❌ Workflow C 자유 조건검색 (지역·용도·가격대·면적·유찰횟수)
- ❌ Workflow D 일별/월별 캘린더
- ❌ 매각물건 사진(전경/개황/내부) URL 노출
- ❌ 매각물건명세서·현황조사서·감정평가서 PDF 다운로드
- ❌ 동산(자동차·중기) 경매
## 차단(BLOCKED) 정책
`courtauction.go.kr` 은 자동화 호출에 매우 민감해서 빠른 연속 조회 시 IP가 약 1시간 차단됩니다. 본 스킬은 다음과 같이 보수적으로 동작합니다.
- 호출 간 최소 2초 + jitter 0~1초 대기 (override: `--min-delay-ms 3000`)
- 세션당 호출 budget 10회 (override: `--max-calls 5`)
- `data.ipcheck === false` 또는 응답 메시지에 "차단" 포함 시 → `BLOCKED` 에러를 즉시 throw, **자동 재시도 금지** (차단 연장 위험)
차단되면 같은 IP에서 약 1시간을 기다려야 합니다. 그 사이에는 다른 IP 또는 사람이 직접 사이트에 접속해서 차단 해제 화면을 거칩니다.
## CLI 사용
```bash
court-auction-notice-search -h
court-auction-notice-search codes courts --pretty | head -40
court-auction-notice-search codes bid-types --pretty
court-auction-notice-search notices --date 2026-04-27 --court-code B000210 --bid-type date --pretty
court-auction-notice-search case --court-code B000210 --case-number "2024타경100001" --pretty
```
## Node.js 사용
```js
const {
searchSaleNotices,
getSaleNoticeDetail,
getCaseByCaseNumber
} = require("court-auction-notice-search");
const notices = await searchSaleNotices({
date: "2026-04-27",
courtCode: "B000210",
bidType: "date"
});
if (notices.items.length > 0) {
const detail = await getSaleNoticeDetail(notices.items[0]);
for (const item of detail.items) {
console.log(item.caseNumber, item.usage, item.address);
console.log(" 감정 ", item.appraisedPrice, "최저 ", item.minimumSalePrice);
}
}
const caseInfo = await getCaseByCaseNumber({
courtCode: "B000210",
caseNumber: "2024타경100001"
});
```
## 사이트 내부 endpoint (직접 캡처한 것)
| 목적 | 메소드 + 경로 | request body |
| --- | --- | --- |
| 매각공고 목록 | `POST /pgj/pgj143/selectRletDspslPbanc.on` | `{"dma_srchDspslPbanc":{"srchYmd","cortOfcCd","bidDvsCd","srchBtnYn":"Y"}}` |
| 매각공고 상세 | `POST /pgj/pgj143/selectRletDspslPbancDtl.on` | `{"dma_srchGnrlPbanc":{"cortOfcCd","dspslDxdyYmd","jdbnCd",...}}` |
| 사건 단건 | `POST /pgj/pgj15A/selectAuctnCsSrchRslt.on` | `{"dma_srchCsDtlInf":{"cortOfcCd","csNo"}}` |
| 법원사무소 코드 | `POST /pgj/pgjComm/selectCortOfcCdLst.on` | `{}` |
세션 cookie(`JSESSIONID`, `WMONID`)는 `GET /pgj/index.on?w2xPath=/pgj/ui/pgj100/PGJ143M01.xml&pgjId=143M01` 으로 사전에 한 번 받아둡니다.
## 설치
```bash
npm install court-auction-notice-search
# Playwright fallback 을 쓰려면 (선택)
npm install rebrowser-playwright # 권장
# 또는
npm install playwright-core
```
## 관련 이슈
- 이 패키지는 [Issue #167](https://github.com/NomaDamas/k-skill/issues/167) 에서 출발했고, A/B 워크플로 + 코드테이블 MVP만 포함합니다.
- 자유 조건검색·캘린더·물건 사진·PDF·동산 경매는 별도 follow-up 이슈로 분리되어 추적됩니다.

View file

@ -90,6 +90,7 @@ npx --yes skills add <owner/repo> \
--skill library-book-search \
--skill k-schoollunch-menu \
--skill korean-character-count \
--skill court-auction-notice-search \
--skill k-skill-cleaner
```
@ -274,7 +275,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
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
export NODE_PATH="$(npm root -g)"
```

View file

@ -22,6 +22,7 @@
- 한국 개인정보처리방침·이용약관 스킬 출시 (kimlawtech/korean-privacy-terms Apache-2.0 업스트림 기반 thin wrapper)
- 한국 부동산 실거래가 조회 스킬 출시
- LH 청약 공고문 조회 스킬 출시
- 법원 경매 부동산 매각공고 조회 스킬 출시 (court-auction-notice-search v1: 매각공고+사건번호 직조회 MVP)
- 의약품 안전 체크 스킬 출시
- 식품 안전 체크 스킬 출시
- 장학금 검색 및 조회 스킬 출시

View file

@ -15,6 +15,14 @@
- 하이패스 사용내역 조회 진입: https://www.hipass.co.kr/usepculr/InitUsePculrTabSearch.do
- 하이패스 사용내역 이용안내: https://www.hipass.co.kr/html/guide/siteguide_6.jsp
- 하이패스 사용내역 이용안내(do): https://www.hipass.co.kr/info/guide/siteguide_6.do
- 법원경매정보 메인: https://www.courtauction.go.kr
- 법원경매정보 부동산매각공고 진입: https://www.courtauction.go.kr/pgj/index.on?w2xPath=/pgj/ui/pgj100/PGJ143M01.xml&pgjId=143M01
- 법원경매정보 경매사건검색 진입: https://www.courtauction.go.kr/pgj/index.on?w2xPath=/pgj/ui/pgj100/PGJ159M00.xml&pgjId=159M00
- 법원경매정보 매각공고 목록 endpoint: https://www.courtauction.go.kr/pgj/pgj143/selectRletDspslPbanc.on
- 법원경매정보 매각공고 상세 endpoint: https://www.courtauction.go.kr/pgj/pgj143/selectRletDspslPbancDtl.on
- 법원경매정보 사건 단건 endpoint: https://www.courtauction.go.kr/pgj/pgj15A/selectAuctnCsSrchRslt.on
- 법원경매정보 법원사무소 코드 endpoint: https://www.courtauction.go.kr/pgj/pgjComm/selectCortOfcCdLst.on
- data.go.kr "법원경매정보 OPEN API 미구축" 회신(2015-009): https://www.data.go.kr/odmc/trublMdat/mdatCase/board.do?id=45
- K League 일정/결과 JSON: https://www.kleague.com/getScheduleList.do
- K League 팀 순위 JSON: https://www.kleague.com/record/teamRank.do
- jerjangmin original `lck-analytics` skill pack: https://github.com/jerjangmin/share/tree/main/SKILL/lck-analytics

20
package-lock.json generated
View file

@ -595,6 +595,10 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/court-auction-notice-search": {
"resolved": "packages/court-auction-notice-search",
"link": true
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"dev": true,
@ -1725,6 +1729,20 @@
"node": ">=18"
}
},
"packages/court-auction-notice-search": {
"version": "0.1.0",
"license": "MIT",
"bin": {
"court-auction-notice-search": "bin/court-auction-notice-search.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"playwright-core": ">=1.40.0",
"rebrowser-playwright": ">=1.40.0"
}
},
"packages/daiso-product-search": {
"version": "0.2.0",
"license": "MIT",
@ -1811,7 +1829,7 @@
}
},
"packages/parking-lot-search": {
"version": "0.1.0",
"version": "0.1.2",
"license": "MIT",
"engines": {
"node": ">=18"

View file

@ -0,0 +1,7 @@
# court-auction-notice-search
## 0.1.0
### Minor Changes
- Initial release. Workflow A (매각공고 목록 + 상세 펼치기) and Workflow B (사건번호 직조회) plus 법원사무소 + 입찰구분 코드테이블. 2-tier transport (direct HTTP first, optional Playwright fallback via `rebrowser-playwright`/`playwright-core`), aggressive throttling (≥2s jitter, 10-call session budget), and `BLOCKED` error on `data.ipcheck === false`. Workflow C (자유 조건검색), Workflow D (일별/월별 캘린더), 매각물건 사진/PDF, 동산 경매는 follow-up 이슈로 분리.

View file

@ -0,0 +1,150 @@
# court-auction-notice-search
대한민국 법원경매정보(`courtauction.go.kr`) 의 **부동산 매각공고**·**사건 정보**를 에이전트가 활용할 수 있는 JSON 형태로 변환해서 돌려주는 read-only 클라이언트.
## What this is (and isn't)
- ✅ Workflow A — 매각공고 목록 + 매각공고 상세(사건/물건 펼치기)
- ✅ Workflow B — 사건번호로 직접 조회 (사건정보·물건내역·매각기일내역·배당요구종기)
- ✅ 코드테이블 — 법원사무소(60+개) + 입찰구분(기일/기간) 코드 매핑
- ✅ 2-tier transport — direct HTTP 1차, Playwright fallback (`rebrowser-playwright` / `playwright-core`)
- ✅ 안티봇 가드 — 호출 간 ≥2초 jitter, 세션당 호출 budget(기본 10회), `data.ipcheck === false` 즉시 throw
- ❌ Workflow C 자유 조건검색(지역/용도/가격대/면적/유찰횟수) — 별도 follow-up 이슈
- ❌ Workflow D 일별/월별 캘린더 — 별도 follow-up 이슈
- ❌ 매각물건 사진 / 매각물건명세서 PDF / 감정평가서 PDF — 별도 follow-up 이슈
- ❌ 동산(자동차·중기) 경매 — 본 패키지 범위 밖
- ❌ 입찰서 자동 작성·자동 제출 — 지원하지 않음 (read-only 정책)
> **참고용**입니다. 실제 입찰 전에는 반드시 해당 법원의 원문 매각공고와 매각물건명세서를 직접 확인하세요. 가격(감정·최저), 매각기일, 매각장소는 정정·취하·연기로 변경될 수 있습니다.
## Install
```bash
npm install court-auction-notice-search
```
Playwright fallback 을 쓰려면 다음 중 하나를 함께 설치 (선택):
```bash
npm install rebrowser-playwright # 봇 차단 우회 친화 브라우저 자동화 (권장)
# 또는
npm install playwright-core
```
## Quickstart
```js
const {
searchSaleNotices,
getSaleNoticeDetail,
getCaseByCaseNumber,
getCourtCodes,
getBidTypes
} = require("court-auction-notice-search");
const courts = await getCourtCodes();
console.log(courts.items.find((c) => c.name === "서울중앙지방법원"));
// { code: "B000210", name: "서울중앙지방법원", branchName: "서울중앙지방법원" }
const notices = await searchSaleNotices({
date: "2026-04-27",
courtCode: "B000210",
bidType: "date" // "기일입찰" / "000331" 도 모두 받음
});
console.log(`매각공고 ${notices.count}건`);
if (notices.items.length > 0) {
const detail = await getSaleNoticeDetail(notices.items[0]);
for (const item of detail.items) {
console.log(item.caseNumber, item.usage, item.address);
console.log(" 감정 ", item.appraisedPrice, "최저 ", item.minimumSalePrice);
}
}
const caseInfo = await getCaseByCaseNumber({
courtCode: "B000210",
caseNumber: "2024타경100001"
});
if (caseInfo.found) {
console.log(caseInfo.caseInfo.caseName, caseInfo.schedule.length);
}
```
## CLI
```bash
court-auction-notice-search -h
court-auction-notice-search codes courts --pretty
court-auction-notice-search codes bid-types --pretty
court-auction-notice-search notices --date 2026-04-27 --court-code B000210 --bid-type date --pretty
court-auction-notice-search case --court-code B000210 --case-number "2024타경100001" --pretty
```
## Public API
- `searchSaleNotices({ date, courtCode?, bidType?, includeRaw?, client? })`
- `date`: `"YYYY-MM-DD"` 또는 `"YYYYMMDD"` (필수)
- `courtCode`: `"B000210"` 형식 또는 `""`(전체)
- `bidType`: `"date"` / `"period"` / `"기일입찰"` / `"기간입찰"` / `"000331"` / `"000332"` / `""`
- returns `{ requestedDate, requestedCourtCode, requestedBidType, count, items[] }`
- `getSaleNoticeDetail(noticeOrKeys, options?)`
- 입력은 `searchSaleNotices` 결과의 `items[i]` 그대로 넘기는 것이 가장 쉽다 (raw 필드를 자동 추출).
- 또는 `{ courtCode, saleDate, judgeDeptCode, bidStartDate?, bidEndDate?, ... }` 형태로 키만 넣어도 된다.
- returns `{ notice, count, items[] }``items[i]``{ caseNumber, itemSeq, usage, address, appraisedPrice, minimumSalePrice, remarks, raw }`
- `getCaseByCaseNumber({ courtCode, caseNumber, includeRaw?, client? })`
- `caseNumber`: `"2024타경100001"` 권장. `"2024-100001"`, `"2024_100001"` 등은 자동 정규화.
- returns `{ found, status, message, caseInfo, items[], schedule[], claimDeadline, relatedCases[], appeals[], stakeholders[], raw? }`
- `getCourtCodes({ client? })` — 법원사무소 코드표 동적 로드
- `getBidTypes()` — 입찰구분 정적 코드표 (기일입찰=`000331`, 기간입찰=`000332`)
- `resolveBidTypeCode(input)`, `describeBidTypeCode(code)` — 코드 변환 헬퍼
- `CourtAuctionHttpClient` — direct HTTP 클라이언트. fetchImpl, timeoutMs, minDelayMs, jitterMs, maxCallsPerSession 모두 override 가능.
- `CourtAuctionPlaywrightClient``playwright-core` / `rebrowser-playwright` 가 있을 때만 사용. `postJson(endpointKey, body)` 시그니처는 동일.
- `isPlaywrightFallbackAvailable()` — fallback 모듈 설치 여부.
- 에러 헬퍼: `createBlockedError`, `createUpstreamError`, `createNetworkError`.
## Error model
- `error.code === "BLOCKED"``data.ipcheck === false`. **1시간 대기** 후 재시도. 자동 재시도 안 함.
- `error.code === "BUDGET_EXCEEDED"` — 세션당 호출 budget 초과. 새 클라이언트를 만들거나 `maxCallsPerSession` 을 명시적으로 늘릴 것.
- `error.code === "UPSTREAM_ERROR"` — 사이트가 generic error 를 돌려준 경우. `error.upstreamMessage` 확인.
- `error.code === "NETWORK_ERROR"` — 타임아웃/연결 실패. `error.cause` 에 원본 에러.
- `error.code === "PLAYWRIGHT_UNAVAILABLE"` — Playwright fallback 모듈이 없음.
## Throttling defaults
- 호출 간 최소 2000ms + jitter 0~1000ms
- 세션당 10 호출 budget
- 타임아웃 15s
```js
const { CourtAuctionHttpClient } = require("court-auction-notice-search");
const client = new CourtAuctionHttpClient({
minDelayMs: 3000, // 더 느리게
jitterMs: 2000,
maxCallsPerSession: 5, // 더 보수적으로
timeoutMs: 30_000
});
const notices = await searchSaleNotices({ date: "2026-04-27", client });
```
## Endpoints used
discovery 시 직접 캡처한 사이트 내부 endpoint:
| 목적 | 메소드 + 경로 | request body 핵심 키 |
| --- | --- | --- |
| 매각공고 목록 | `POST /pgj/pgj143/selectRletDspslPbanc.on` | `dma_srchDspslPbanc.{srchYmd, cortOfcCd, bidDvsCd, srchBtnYn:"Y"}` |
| 매각공고 상세 | `POST /pgj/pgj143/selectRletDspslPbancDtl.on` | `dma_srchGnrlPbanc.{cortOfcCd, dspslDxdyYmd, jdbnCd, ...}` |
| 사건 단건 | `POST /pgj/pgj15A/selectAuctnCsSrchRslt.on` | `dma_srchCsDtlInf.{cortOfcCd, csNo}` |
| 법원사무소 | `POST /pgj/pgjComm/selectCortOfcCdLst.on` | `{}` |
## Verification
```bash
npm run lint
npm run test
```
## License
MIT. Read-only client. 사이트 운영 정책을 준수해 주세요.

View file

@ -0,0 +1,19 @@
#!/usr/bin/env node
"use strict";
const { main } = require("../src/cli");
main(process.argv.slice(2)).then(
(code) => {
process.exit(typeof code === "number" ? code : 0);
},
(err) => {
const detail = err && err.code ? `[${err.code}] ` : "";
const message = err && err.message ? err.message : String(err);
process.stderr.write(`court-auction-notice-search: ${detail}${message}\n`);
if (err && err.upstreamMessage && err.upstreamMessage !== message) {
process.stderr.write(` upstream: ${err.upstreamMessage}\n`);
}
process.exit(1);
}
);

View file

@ -0,0 +1,41 @@
{
"name": "court-auction-notice-search",
"version": "0.1.0",
"description": "Korean court auction (대법원경매정보) real estate sale notice and case lookup with direct HTTP + Playwright fallback",
"license": "MIT",
"main": "src/index.js",
"bin": {
"court-auction-notice-search": "bin/court-auction-notice-search.js"
},
"files": [
"src",
"bin",
"README.md"
],
"engines": {
"node": ">=18"
},
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/NomaDamas/k-skill.git"
},
"keywords": [
"k-skill",
"korea",
"court-auction",
"courtauction",
"real-estate",
"auction-notice"
],
"scripts": {
"lint": "node --check src/index.js && node --check src/normalize.js && node --check src/codetables/index.js && node --check src/transport/http.js && node --check src/transport/playwright.js && node --check src/cli.js && node --check bin/court-auction-notice-search.js && node --check test/normalize.test.js && node --check test/transport.test.js && node --check test/index.test.js && node --check test/cli.test.js",
"test": "node --test"
},
"optionalDependencies": {
"playwright-core": ">=1.40.0",
"rebrowser-playwright": ">=1.40.0"
}
}

View file

@ -0,0 +1,203 @@
"use strict";
const {
searchSaleNotices,
getSaleNoticeDetail,
getCaseByCaseNumber,
getCourtCodes,
getBidTypes,
resolveBidTypeCode,
CourtAuctionHttpClient
} = require("./index");
const USAGE = `court-auction-notice-search — 대법원경매정보(courtauction.go.kr) read-only client
USAGE
court-auction-notice-search notices --date <YYYY-MM-DD> [--court-code <B000210>] [--bid-type date|period]
court-auction-notice-search notice-detail --court-code <B000210> --sale-date <YYYY-MM-DD> --judge-dept-code <jdbnCd>
[--bid-start <YYYY-MM-DD>] [--bid-end <YYYY-MM-DD>] [--bid-type date|period]
court-auction-notice-search case --court-code <B000210> --case-number <2024타경100001>
court-auction-notice-search codes courts
court-auction-notice-search codes bid-types
GLOBAL FLAGS
--json Print JSON output (default)
--pretty Pretty-print JSON
--include-raw=false Strip the raw passthrough field from output
--timeout-ms <ms> HTTP timeout per request (default: 15000)
--min-delay-ms <ms> Minimum jitter between calls (default: 2000)
--max-calls <N> Per-session call budget (default: 10)
-h, --help Show this help
NOTES
- The court auction site blocks bursty traffic by IP for ~1 hour. Keep traffic slow.
- On block detection (data.ipcheck === false), the client throws a BLOCKED error and stops.
- This skill is read-only. Always re-verify actual auction data on the official site before bidding.
`;
function parseArgs(argv) {
const args = { _: [], flags: {} };
let i = 0;
while (i < argv.length) {
const token = argv[i];
if (token === "--") {
args._.push(...argv.slice(i + 1));
break;
}
if (token === "-h" || token === "--help") {
args.flags.help = true;
i += 1;
continue;
}
if (token.startsWith("--")) {
const eq = token.indexOf("=");
let name;
let value;
if (eq !== -1) {
name = token.slice(2, eq);
value = token.slice(eq + 1);
} else {
name = token.slice(2);
const next = argv[i + 1];
if (next === undefined || next.startsWith("-")) {
value = true;
} else {
value = next;
i += 1;
}
}
args.flags[name] = value;
} else {
args._.push(token);
}
i += 1;
}
return args;
}
function flagToInt(flags, name) {
if (flags[name] === undefined) return undefined;
const n = Number(flags[name]);
if (!Number.isFinite(n)) {
throw new Error(`--${name} must be an integer, got ${flags[name]}`);
}
return n;
}
function buildClient(flags) {
const opts = {};
const timeoutMs = flagToInt(flags, "timeout-ms");
if (timeoutMs !== undefined) opts.timeoutMs = timeoutMs;
const minDelayMs = flagToInt(flags, "min-delay-ms");
if (minDelayMs !== undefined) opts.minDelayMs = minDelayMs;
const maxCalls = flagToInt(flags, "max-calls");
if (maxCalls !== undefined) opts.maxCallsPerSession = maxCalls;
return new CourtAuctionHttpClient(opts);
}
function emit(value, flags) {
const pretty = flags.pretty || flags["pretty-print"];
const json = pretty
? JSON.stringify(value, null, 2)
: JSON.stringify(value);
process.stdout.write(`${json}\n`);
}
function shouldIncludeRaw(flags) {
if (flags["include-raw"] === undefined) return undefined;
if (flags["include-raw"] === false || flags["include-raw"] === "false") return false;
return true;
}
async function runNotices(flags) {
const date = flags.date;
if (!date) throw new Error("--date is required (YYYY-MM-DD)");
const includeRaw = shouldIncludeRaw(flags);
const result = await searchSaleNotices({
date,
courtCode: flags["court-code"] || flags.court || "",
bidType: flags["bid-type"] || flags.bidType,
client: buildClient(flags),
includeRaw: includeRaw === undefined ? true : includeRaw
});
emit(result, flags);
}
async function runNoticeDetail(flags) {
const result = await getSaleNoticeDetail(
{
courtCode: flags["court-code"],
saleDate: flags["sale-date"],
judgeDeptCode: flags["judge-dept-code"],
judgeDeptName: flags["judge-dept-name"],
judgeDeptPhone: flags["judge-dept-phone"],
salePlace: flags["sale-place"],
bidStartDate: flags["bid-start"],
bidEndDate: flags["bid-end"],
bidType: flags["bid-type"]
},
{
client: buildClient(flags),
includeRaw: shouldIncludeRaw(flags) !== false
}
);
emit(result, flags);
}
async function runCase(flags) {
const result = await getCaseByCaseNumber({
courtCode: flags["court-code"],
caseNumber: flags["case-number"] || flags.caseNumber,
client: buildClient(flags),
includeRaw: shouldIncludeRaw(flags) !== false
});
emit(result, flags);
}
async function runCodes(positional, flags) {
const sub = positional[1];
if (sub === "bid-types" || sub === "bid_types" || sub === "bid") {
emit({ items: getBidTypes() }, flags);
return;
}
if (!sub || sub === "courts") {
const result = await getCourtCodes({ client: buildClient(flags) });
emit(result, flags);
return;
}
throw new Error(`Unknown codes subcommand: ${sub}. Try "courts" or "bid-types".`);
}
async function main(argv) {
const args = parseArgs(argv || []);
if (args.flags.help || args._.length === 0) {
process.stdout.write(USAGE);
return 0;
}
if (args.flags["bid-type"] !== undefined && args.flags["bid-type"] !== true) {
resolveBidTypeCode(args.flags["bid-type"]);
}
const command = args._[0];
switch (command) {
case "notices":
await runNotices(args.flags);
return 0;
case "notice-detail":
case "noticeDetail":
await runNoticeDetail(args.flags);
return 0;
case "case":
await runCase(args.flags);
return 0;
case "codes":
await runCodes(args._, args.flags);
return 0;
default:
process.stderr.write(`Unknown command: ${command}\n${USAGE}`);
return 2;
}
}
module.exports = { main, parseArgs, USAGE };

View file

@ -0,0 +1,16 @@
{
"bidTypes": [
{
"code": "000331",
"name": "기일입찰",
"alias": "date",
"description": "Date-based bidding (single auction date with sealed-bid open in the courtroom)"
},
{
"code": "000332",
"name": "기간입찰",
"alias": "period",
"description": "Period-based bidding (sealed bids accepted across a window, opened on a designated date)"
}
]
}

View file

@ -0,0 +1,76 @@
"use strict";
const path = require("node:path");
const fs = require("node:fs");
const bidTypesData = JSON.parse(
fs.readFileSync(path.join(__dirname, "bid-types.json"), "utf8")
);
const BID_TYPES = Object.freeze(
bidTypesData.bidTypes.map((entry) => Object.freeze({ ...entry }))
);
const BID_TYPE_BY_ALIAS = new Map();
const BID_TYPE_BY_CODE = new Map();
const BID_TYPE_BY_NAME = new Map();
for (const entry of BID_TYPES) {
BID_TYPE_BY_ALIAS.set(entry.alias, entry);
BID_TYPE_BY_CODE.set(entry.code, entry);
BID_TYPE_BY_NAME.set(entry.name, entry);
}
/**
* Resolve a bid type input (alias / code / korean name) to its raw `bidDvsCd`.
* Returns "" if the input is empty/undefined (meaning "all types").
* Pass-through (fail-open) on unknown codes keeps API resilient if the
* upstream adds new types we have not seen yet.
*/
function resolveBidTypeCode(input) {
if (input === undefined || input === null || input === "") {
return "";
}
const value = String(input).trim();
if (value === "") {
return "";
}
const aliasMatch = BID_TYPE_BY_ALIAS.get(value.toLowerCase());
if (aliasMatch) return aliasMatch.code;
const codeMatch = BID_TYPE_BY_CODE.get(value);
if (codeMatch) return codeMatch.code;
const nameMatch = BID_TYPE_BY_NAME.get(value);
if (nameMatch) return nameMatch.code;
// Fail-open: pass raw value through. If the user supplied an unknown
// code (e.g. a future "기간/기일혼합입찰") the upstream will reject or
// return empty. We do not silently rewrite it.
return value;
}
/**
* Resolve raw `bidDvsCd` to the human-readable Korean name.
* Returns the input unchanged if not recognized (fail-open).
*/
function describeBidTypeCode(code) {
if (code === undefined || code === null || code === "") {
return "";
}
const value = String(code).trim();
const match = BID_TYPE_BY_CODE.get(value);
return match ? match.name : value;
}
function listBidTypes() {
return BID_TYPES.map((entry) => ({ ...entry }));
}
module.exports = {
BID_TYPES,
resolveBidTypeCode,
describeBidTypeCode,
listBidTypes
};

View file

@ -0,0 +1,243 @@
"use strict";
const {
CourtAuctionHttpClient,
ENDPOINT_PATHS,
WARMUP_PATH,
DEFAULT_BASE_URL,
DEFAULT_USER_AGENT,
createBlockedError,
createUpstreamError,
createNetworkError
} = require("./transport/http");
const { CourtAuctionPlaywrightClient, isFallbackAvailable } = require("./transport/playwright");
const {
resolveBidTypeCode,
describeBidTypeCode,
listBidTypes,
BID_TYPES
} = require("./codetables");
const {
normalizeNoticeListResponse,
normalizeNoticeDetailResponse,
normalizeCourtCodesResponse,
normalizeCaseDetailResponse
} = require("./normalize");
function toYmd(input, label) {
if (input === null || input === undefined || input === "") {
throw new Error(`${label} is required (YYYY-MM-DD or YYYYMMDD)`);
}
const value = String(input).trim();
const compact = value.replace(/[^0-9]/g, "");
if (!/^\d{8}$/.test(compact)) {
throw new Error(`${label} must be YYYY-MM-DD or YYYYMMDD, got "${input}"`);
}
return compact;
}
function normalizeCaseNumber(input) {
if (input === null || input === undefined) {
throw new Error("caseNumber is required (e.g. 2024타경100001)");
}
const value = String(input).trim();
if (value === "") {
throw new Error("caseNumber must not be blank");
}
if (/^\d{4}타경\d+$/.test(value)) {
return value;
}
const match = value.match(/^(\d{4})\s*[-_\s]?\s*(\d+)$/);
if (match) {
return `${match[1]}타경${match[2]}`;
}
return value;
}
function ensureCourtCode(input) {
if (input === null || input === undefined) {
throw new Error("courtCode is required (e.g. B000210 for 서울중앙지방법원)");
}
const value = String(input).trim();
if (!/^B\d{6}$/.test(value)) {
throw new Error(`courtCode must look like "B000210", got "${input}"`);
}
return value;
}
function pickClientOptions(input) {
if (!input || typeof input !== "object") return {};
const out = {};
if (input.baseUrl !== undefined) out.baseUrl = input.baseUrl;
if (input.userAgent !== undefined) out.userAgent = input.userAgent;
if (input.timeoutMs !== undefined) out.timeoutMs = input.timeoutMs;
if (input.fetchImpl !== undefined) out.fetchImpl = input.fetchImpl;
if (input.minDelayMs !== undefined) out.minDelayMs = input.minDelayMs;
if (input.jitterMs !== undefined) out.jitterMs = input.jitterMs;
if (input.maxCallsPerSession !== undefined) {
out.maxCallsPerSession = input.maxCallsPerSession;
}
if (input.now !== undefined) out.now = input.now;
if (input.delayImpl !== undefined) out.delayImpl = input.delayImpl;
return out;
}
function ensureClient(client, options) {
if (client && typeof client.postJson === "function") return client;
return new CourtAuctionHttpClient(pickClientOptions(options));
}
async function searchSaleNotices(params = {}) {
const date = toYmd(params.date, "date");
const courtCodeRaw =
params.courtCode === undefined || params.courtCode === null ? "" : String(params.courtCode).trim();
const courtCode = courtCodeRaw === "" ? "" : ensureCourtCode(courtCodeRaw);
const bidTypeCode = resolveBidTypeCode(params.bidType);
const client = ensureClient(params.client, params);
const body = {
dma_srchDspslPbanc: {
srchYmd: date,
cortOfcCd: courtCode,
bidDvsCd: bidTypeCode,
srchBtnYn: "Y"
}
};
const raw = await client.postJson("notices", body);
return normalizeNoticeListResponse(raw, {
requestedDate: `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}`,
requestedCourtCode: courtCode || null,
requestedBidType: bidTypeCode
? { code: bidTypeCode, name: describeBidTypeCode(bidTypeCode) }
: null,
includeRaw: params.includeRaw !== false
});
}
function pickNoticeKeys(notice) {
if (!notice || typeof notice !== "object") return null;
const raw = notice.raw && typeof notice.raw === "object" ? notice.raw : notice;
return raw;
}
function buildNoticeDetailBody(input) {
if (!input || typeof input !== "object") {
throw new Error("getSaleNoticeDetail requires an object argument");
}
const raw = pickNoticeKeys(input) || {};
const cortOfcCd =
input.cortOfcCd || input.courtCode || raw.cortOfcCd || raw.courtCode || "";
if (!cortOfcCd) {
throw new Error("getSaleNoticeDetail requires courtCode (cortOfcCd)");
}
const dspslDxdyYmd =
raw.dspslDxdyYmd ||
(input.saleDate ? toYmd(input.saleDate, "saleDate") : "") ||
(input.dspslDxdyYmd ? toYmd(input.dspslDxdyYmd, "dspslDxdyYmd") : "");
if (!dspslDxdyYmd) {
throw new Error("getSaleNoticeDetail requires saleDate (dspslDxdyYmd)");
}
const bidBgngYmd =
raw.bidBgngYmd ||
(input.bidStartDate ? toYmd(input.bidStartDate, "bidStartDate") : "") ||
"";
const bidEndYmd =
raw.bidEndYmd ||
(input.bidEndDate ? toYmd(input.bidEndDate, "bidEndDate") : "") ||
"";
const jdbnCd = raw.jdbnCd || input.judgeDeptCode || "";
if (!jdbnCd) {
throw new Error(
"getSaleNoticeDetail requires judgeDeptCode (jdbnCd) — this is the encrypted token from the list response"
);
}
const bidDvsCd =
raw.bidDvsCd ||
raw.intgCd ||
resolveBidTypeCode(input.bidType) ||
input.bidDvsCd ||
"";
return {
dma_srchGnrlPbanc: {
cortOfcCd: ensureCourtCode(cortOfcCd),
dspslDxdyYmd: toYmd(dspslDxdyYmd, "dspslDxdyYmd"),
bidBgngYmd: bidBgngYmd ? toYmd(bidBgngYmd, "bidBgngYmd") : "",
bidEndYmd: bidEndYmd ? toYmd(bidEndYmd, "bidEndYmd") : "",
jdbnCd,
cortAuctnJdbnNm: raw.cortAuctnJdbnNm || input.judgeDeptName || "",
jdbnTelno: raw.jdbnTelno || input.judgeDeptPhone || "",
dspslPlcNm: raw.dspslPlcNm || input.salePlace || "",
fstDspslHm: raw.fstDspslHm || "",
scndDspslHm: raw.scndDspslHm || "",
thrdDspslHm: raw.thrdDspslHm || "",
fothDspslHm: raw.fothDspslHm || "",
bidDvsCd
}
};
}
async function getSaleNoticeDetail(input, options = {}) {
const body = buildNoticeDetailBody(input);
const client = ensureClient(options.client || (input && input.client), options);
const raw = await client.postJson("noticeDetail", body);
return normalizeNoticeDetailResponse(raw, {
includeRaw: options.includeRaw !== false
});
}
async function getCaseByCaseNumber(params = {}) {
const courtCode = ensureCourtCode(params.courtCode);
const caseNumber = normalizeCaseNumber(params.caseNumber);
const client = ensureClient(params.client, params);
const body = {
dma_srchCsDtlInf: {
cortOfcCd: courtCode,
csNo: caseNumber
}
};
const raw = await client.postJson("caseDetail", body);
return normalizeCaseDetailResponse(raw, {
includeRaw: params.includeRaw !== false
});
}
async function getCourtCodes(options = {}) {
const client = ensureClient(options.client, options);
const raw = await client.postJson("courts", {});
return normalizeCourtCodesResponse(raw);
}
function getBidTypes() {
return listBidTypes();
}
module.exports = {
ENDPOINT_PATHS,
WARMUP_PATH,
DEFAULT_BASE_URL,
DEFAULT_USER_AGENT,
BID_TYPES,
CourtAuctionHttpClient,
CourtAuctionPlaywrightClient,
isPlaywrightFallbackAvailable: isFallbackAvailable,
createBlockedError,
createUpstreamError,
createNetworkError,
resolveBidTypeCode,
describeBidTypeCode,
searchSaleNotices,
getSaleNoticeDetail,
buildNoticeDetailBody,
getCaseByCaseNumber,
getCourtCodes,
getBidTypes
};

View file

@ -0,0 +1,324 @@
"use strict";
const { describeBidTypeCode } = require("./codetables");
function nullIfBlank(value) {
if (value === null || value === undefined) return null;
const trimmed = String(value).trim();
return trimmed === "" ? null : trimmed;
}
function parseAmount(value) {
if (value === null || value === undefined) return null;
const trimmed = String(value).trim();
if (trimmed === "") return null;
const cleaned = trimmed.replace(/[, ]/g, "").replace(/원$/, "");
if (cleaned === "" || cleaned === "-") return null;
const num = Number(cleaned);
return Number.isFinite(num) ? num : null;
}
function formatYmd(value) {
if (value === null || value === undefined) return null;
const trimmed = String(value).trim();
if (!/^\d{8}$/.test(trimmed)) return nullIfBlank(trimmed);
return `${trimmed.slice(0, 4)}-${trimmed.slice(4, 6)}-${trimmed.slice(6, 8)}`;
}
function formatHm(value) {
if (value === null || value === undefined) return null;
const trimmed = String(value).trim();
if (!/^\d{3,4}$/.test(trimmed)) return nullIfBlank(trimmed);
const padded = trimmed.padStart(4, "0");
return `${padded.slice(0, 2)}:${padded.slice(2, 4)}`;
}
function ensureRow(row) {
return row && typeof row === "object" ? row : {};
}
function normalizeNoticeListResponse(rawPayload, options = {}) {
const data = rawPayload && typeof rawPayload === "object" ? rawPayload.data : null;
const list =
data && Array.isArray(data.dlt_rletDspslPbancLst)
? data.dlt_rletDspslPbancLst
: [];
const includeRaw = options.includeRaw !== false;
const items = list.map((row) => normalizeNoticeRow(row, includeRaw));
return {
requestedDate: options.requestedDate || null,
requestedCourtCode: options.requestedCourtCode || null,
requestedBidType: options.requestedBidType || null,
count: items.length,
items
};
}
function normalizeNoticeRow(rawRow, includeRaw) {
const row = ensureRow(rawRow);
const out = {
noticeId: nullIfBlank(row.dspslRealId),
courtCode: nullIfBlank(row.cortOfcCd),
courtName: nullIfBlank(row.cortOfcNm),
courtBranchName: nullIfBlank(row.cortSptNm),
judgeDeptCode: nullIfBlank(row.jdbnCd),
judgeDeptName: nullIfBlank(row.cortAuctnJdbnNm),
printJudgeDeptName: nullIfBlank(row.printJdbnNm),
judgeDeptPhone: nullIfBlank(row.jdbnTelno),
bidTypeCode: nullIfBlank(row.bidDvsCd) || nullIfBlank(row.intgCd),
bidTypeName:
nullIfBlank(row.intgCdNm) ||
describeBidTypeCode(nullIfBlank(row.bidDvsCd) || "") ||
null,
saleDate: formatYmd(row.dspslDxdyYmd),
bidStartDate: formatYmd(row.bidBgngYmd),
bidEndDate: formatYmd(row.bidEndYmd),
bidPeriodLabel: nullIfBlank(row.realBidPerd),
salePlace: nullIfBlank(row.dspslPlcNm),
saleTimes: collectSaleTimes(row),
correctionCount: parseAmount(row.corCnt) || 0,
cancellationCount: parseAmount(row.canCnt) || 0
};
if (includeRaw) {
out.raw = { ...row };
}
return out;
}
function collectSaleTimes(row) {
const result = [];
for (const key of ["fstDspslHm", "scndDspslHm", "thrdDspslHm", "fothDspslHm"]) {
const formatted = formatHm(row[key]);
if (formatted) result.push(formatted);
}
return result;
}
function normalizeNoticeDetailResponse(rawPayload, options = {}) {
const data = rawPayload && typeof rawPayload === "object" ? rawPayload.data : null;
const meta = data && typeof data.dma_srchGnrlPbanc === "object" ? data.dma_srchGnrlPbanc : {};
const list =
data && Array.isArray(data.dlt_gnrlPbancLst) ? data.dlt_gnrlPbancLst : [];
const includeRaw = options.includeRaw !== false;
const noticeMeta = {
courtCode: nullIfBlank(meta.cortOfcCd),
saleDate: formatYmd(meta.dspslDxdyYmd),
bidStartDate: formatYmd(meta.bidBgngYmd),
bidEndDate: formatYmd(meta.bidEndYmd),
judgeDeptCode: nullIfBlank(meta.jdbnCd),
judgeDeptName: nullIfBlank(meta.cortAuctnJdbnNm),
judgeDeptPhone: nullIfBlank(meta.jdbnTelno),
salePlace: nullIfBlank(meta.dspslPlcNm),
saleTimes: collectSaleTimes(meta),
bidTypeCode: nullIfBlank(meta.bidDvsCd),
bidTypeName: describeBidTypeCode(nullIfBlank(meta.bidDvsCd) || "") || null
};
const items = list.map((row) => normalizeNoticeDetailRow(row, includeRaw));
const result = {
notice: noticeMeta,
count: items.length,
items
};
if (includeRaw) {
result.raw = { dma_srchGnrlPbanc: { ...meta } };
}
return result;
}
function normalizeNoticeDetailRow(rawRow, includeRaw) {
const row = ensureRow(rawRow);
const out = {
caseNumber: nullIfBlank(row.csNo),
itemSeq: nullIfBlank(row.dspslSeq),
usage: nullIfBlank(row.usgNm),
address: nullIfBlank(row.st),
appraisedPrice: parseAmount(row.aeeEvlAmt),
minimumSalePrice: parseAmount(row.lwsDspslPrc),
remarks: nullIfBlank(row.dspslRmk)
};
if (includeRaw) {
out.raw = { ...row };
}
return out;
}
function normalizeCourtCodesResponse(rawPayload) {
const data = rawPayload && typeof rawPayload === "object" ? rawPayload.data : null;
const list = data && Array.isArray(data.result) ? data.result : [];
const items = list.map((row) => {
const r = ensureRow(row);
return {
code: nullIfBlank(r.cortOfcCd),
name: nullIfBlank(r.cortOfcNm),
branchName: nullIfBlank(r.cortSptNm)
};
});
return { count: items.length, items };
}
function normalizeCaseDetailResponse(rawPayload, options = {}) {
const data = rawPayload && typeof rawPayload === "object" ? rawPayload.data : null;
const status = rawPayload && typeof rawPayload.status === "number" ? rawPayload.status : null;
const upstreamMessage =
rawPayload && typeof rawPayload.message === "string" ? rawPayload.message : null;
const includeRaw = options.includeRaw !== false;
if (!data || !data.dma_csBasInf) {
return {
found: false,
status,
message: upstreamMessage,
caseInfo: null,
items: [],
schedule: [],
claimDeadline: null,
relatedCases: [],
appeals: [],
stakeholders: [],
raw: includeRaw ? rawPayload || null : undefined
};
}
const basis = data.dma_csBasInf;
const caseInfo = {
courtCode: nullIfBlank(basis.cortOfcCd),
courtName: nullIfBlank(basis.cortOfcNm),
courtBranchName: nullIfBlank(basis.cortSptNm),
caseNumber: nullIfBlank(basis.csNo),
caseName: nullIfBlank(basis.csNm),
caseReceiptDate: formatYmd(basis.csRcptYmd),
caseStartDate: formatYmd(basis.csCmdcYmd),
claimAmount: parseAmount(basis.clmAmt),
appealFlag: nullIfBlank(basis.rletApalYn),
suspensionStatusCode: nullIfBlank(basis.auctnSuspStatCd),
finalDispositionCode: nullIfBlank(basis.ultmtDvsCd),
finalDispositionDate: formatYmd(basis.csUltmtYmd),
progressStatusCode: nullIfBlank(basis.csProgStatCd),
progressSuspensionReason: nullIfBlank(basis.csProgSuspRsn),
judgeOrAuxiliaryName: nullIfBlank(basis.jdgeAojAsstnNm),
judgeDeptCode: nullIfBlank(basis.jdbnCd),
judgeDeptName: nullIfBlank(basis.cortAuctnJdbnNm),
judgeDeptPhone: nullIfBlank(basis.jdbnTelno),
executorOfficePhone: nullIfBlank(basis.execrCsTelno),
courtTypeCode: nullIfBlank(basis.cortTypCd),
userCaseNumber: nullIfBlank(basis.userCsNo),
movableRealEstateCode: nullIfBlank(basis.mvprpRletDvsCd)
};
const items = (Array.isArray(data.dlt_rletCsDspslObjctLst)
? data.dlt_rletCsDspslObjctLst
: []
).map((row) => {
const r = ensureRow(row);
return {
itemSeq: nullIfBlank(r.dspslObjctSeq),
address: nullIfBlank(r.userSt),
claimDeadlineDate: formatYmd(r.userLstprdYmd),
caseNumber: nullIfBlank(r.csNo),
courtCode: nullIfBlank(r.cortOfcCd)
};
});
const schedule = (Array.isArray(data.dlt_rletCsGdsDtsDxdyInf)
? data.dlt_rletCsGdsDtsDxdyInf
: []
).map((row) => {
const r = ensureRow(row);
return {
itemSeq: nullIfBlank(r.dspslGdsSeq),
eventSeq: nullIfBlank(r.dxdySeq),
saleDate: formatYmd(r.dspslDxdyYmd),
minimumSalePrice: parseAmount(r.lwsDspslPrc),
appraisedPrice: parseAmount(r.aeeEvlAmt),
resultCode: nullIfBlank(r.rsltCd)
};
});
const claimDeadlineRows = Array.isArray(data.dlt_dstrtDemnLstprdDts)
? data.dlt_dstrtDemnLstprdDts
: [];
const claimDeadline = claimDeadlineRows.length
? {
deadlineDate: formatYmd(claimDeadlineRows[0].dstrtDemnLstprdYmd),
announcementDate: formatYmd(
claimDeadlineRows[0].dstrtDemnLstprdPbancYmd
)
}
: null;
const relatedCases = (Array.isArray(data.dlt_rletReltCsLst)
? data.dlt_rletReltCsLst
: []
).map((row) => {
const r = ensureRow(row);
return {
caseNumber: nullIfBlank(r.userReltCsNo) || nullIfBlank(r.reltCsNo),
courtCode: nullIfBlank(r.reltCortOfcCd),
courtName: nullIfBlank(r.cortOfcNm),
courtBranchName: nullIfBlank(r.cortSptNm),
relationCode: nullIfBlank(r.reltCsDvsCd)
};
});
const appeals = (Array.isArray(data.dlt_csApalRaplDts)
? data.dlt_csApalRaplDts
: []
).map((row) => {
const r = ensureRow(row);
return {
appellant: nullIfBlank(r.apalPrpndr),
appealCaseNumber: nullIfBlank(r.apalCsNo),
appealResult: nullIfBlank(r.apalRslt),
reAppealCaseNumber: nullIfBlank(r.raplCsNo),
reAppealResult: nullIfBlank(r.raplRslt),
filedDate: formatYmd(r.printApalAplyYmd),
confirmationFlag: nullIfBlank(r.cfmtnYnRslt)
};
});
const stakeholders = (Array.isArray(data.dlt_rletCsIntrpsLst)
? data.dlt_rletCsIntrpsLst
: []
).map((row) => {
const r = ensureRow(row);
return {
kind: nullIfBlank(r.auctnIntrpsDvsNm1) || nullIfBlank(r.auctnIntrpsDvsNm2),
name: nullIfBlank(r.intrpsNm1) || nullIfBlank(r.intrpsNm2)
};
});
const result = {
found: true,
status,
message: upstreamMessage,
caseInfo,
items,
schedule,
claimDeadline,
relatedCases,
appeals,
stakeholders
};
if (includeRaw) {
result.raw = rawPayload;
}
return result;
}
module.exports = {
normalizeNoticeListResponse,
normalizeNoticeRow,
normalizeNoticeDetailResponse,
normalizeNoticeDetailRow,
normalizeCourtCodesResponse,
normalizeCaseDetailResponse,
parseAmount,
formatYmd,
formatHm
};

View file

@ -0,0 +1,297 @@
"use strict";
const DEFAULT_BASE_URL = "https://www.courtauction.go.kr";
const DEFAULT_USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
const WARMUP_PATH =
"/pgj/index.on?w2xPath=/pgj/ui/pgj100/PGJ143M01.xml&pgjId=143M01";
const ENDPOINT_PATHS = Object.freeze({
notices: "/pgj/pgj143/selectRletDspslPbanc.on",
noticeDetail: "/pgj/pgj143/selectRletDspslPbancDtl.on",
caseDetail: "/pgj/pgj15A/selectAuctnCsSrchRslt.on",
courts: "/pgj/pgjComm/selectCortOfcCdLst.on"
});
const ENDPOINT_REFERER_HINT = Object.freeze({
notices: "/pgj/index.on?w2xPath=/pgj/ui/pgj100/PGJ143M01.xml&pgjId=143M01",
noticeDetail:
"/pgj/index.on?w2xPath=/pgj/ui/pgj100/PGJ143M01.xml&pgjId=143M01",
caseDetail:
"/pgj/index.on?w2xPath=/pgj/ui/pgj100/PGJ159M00.xml&pgjId=159M00",
courts: "/pgj/index.on?w2xPath=/pgj/ui/pgj100/PGJ143M01.xml&pgjId=143M01"
});
function createBlockedError(message, payload) {
const error = new Error(
message ||
"Court Auction site blocked this client (ipcheck=false). Wait at least 1 hour before retrying with the same IP."
);
error.code = "BLOCKED";
error.upstreamUrl = "courtauction.go.kr";
if (payload && typeof payload === "object") {
error.upstreamPayload = payload;
if (payload.message) error.upstreamMessage = payload.message;
}
return error;
}
function createUpstreamError(payload, endpointPath, statusCode) {
const upstreamMessage =
payload &&
payload.errors &&
typeof payload.errors === "object" &&
payload.errors.errorMessage
? String(payload.errors.errorMessage)
: "";
const error = new Error(
`Court Auction request failed for ${endpointPath}` +
(upstreamMessage ? ` (${upstreamMessage})` : "")
);
error.code = "UPSTREAM_ERROR";
error.statusCode = typeof statusCode === "number" ? statusCode : null;
error.upstreamUrl = endpointPath;
if (payload && typeof payload === "object") {
error.upstreamPayload = payload;
if (upstreamMessage) error.upstreamMessage = upstreamMessage;
}
return error;
}
function createNetworkError(cause, endpointPath) {
const error = new Error(
`Court Auction network error for ${endpointPath}: ${cause && cause.message ? cause.message : cause}`
);
error.code = "NETWORK_ERROR";
error.upstreamUrl = endpointPath;
if (cause) error.cause = cause;
return error;
}
function buildCookieHeader(cookieJar) {
const entries = [];
for (const [key, value] of cookieJar) {
entries.push(`${key}=${value}`);
}
return entries.join("; ");
}
function ingestSetCookie(cookieJar, headers) {
if (!headers || typeof headers.getSetCookie !== "function") {
const raw = headers && typeof headers.get === "function" ? headers.get("set-cookie") : null;
if (!raw) return;
parseAndStoreCookieLine(cookieJar, raw);
return;
}
const lines = headers.getSetCookie() || [];
for (const line of lines) {
parseAndStoreCookieLine(cookieJar, line);
}
}
function parseAndStoreCookieLine(cookieJar, line) {
if (!line) return;
const firstSegment = String(line).split(";")[0];
const eqIndex = firstSegment.indexOf("=");
if (eqIndex <= 0) return;
const name = firstSegment.slice(0, eqIndex).trim();
const value = firstSegment.slice(eqIndex + 1).trim();
if (!name) return;
cookieJar.set(name, value);
}
function delay(ms) {
if (ms <= 0) return Promise.resolve();
return new Promise((resolve) => setTimeout(resolve, ms));
}
function jitter(min, jitterMs) {
const extra = Math.floor(Math.random() * Math.max(0, jitterMs));
return Math.max(0, min) + extra;
}
class CourtAuctionHttpClient {
constructor(options = {}) {
this.baseUrl = String(options.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
this.fetchImpl = options.fetchImpl || global.fetch;
if (typeof this.fetchImpl !== "function") {
throw new Error(
"CourtAuctionHttpClient requires a fetch implementation. Pass options.fetchImpl or run on Node 18+."
);
}
this.userAgent = options.userAgent || DEFAULT_USER_AGENT;
this.timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 15000;
this.minDelayMs = Number.isFinite(options.minDelayMs) ? options.minDelayMs : 2000;
this.jitterMs = Number.isFinite(options.jitterMs) ? options.jitterMs : 1000;
this.maxCallsPerSession = Number.isFinite(options.maxCallsPerSession)
? options.maxCallsPerSession
: 10;
this.cookieJar = new Map();
this.warmedUp = false;
this.callsSoFar = 0;
this.lastCallAt = 0;
this.now = typeof options.now === "function" ? options.now : () => Date.now();
this.delayImpl = typeof options.delayImpl === "function" ? options.delayImpl : delay;
}
resetSession() {
this.cookieJar = new Map();
this.warmedUp = false;
this.callsSoFar = 0;
this.lastCallAt = 0;
}
buildHeaders(endpointKey, extra = {}) {
const refererPath = ENDPOINT_REFERER_HINT[endpointKey] || ENDPOINT_REFERER_HINT.notices;
const headers = {
"User-Agent": this.userAgent,
Accept: "application/json, text/javascript, */*; q=0.01",
"Accept-Language": "ko-KR,ko;q=0.9,en;q=0.8",
Origin: this.baseUrl,
Referer: `${this.baseUrl}${refererPath}`,
"X-Requested-With": "XMLHttpRequest"
};
const cookieHeader = buildCookieHeader(this.cookieJar);
if (cookieHeader) {
headers.Cookie = cookieHeader;
}
return Object.assign(headers, extra);
}
async warmup() {
if (this.warmedUp) return;
const url = `${this.baseUrl}${WARMUP_PATH}`;
const controller = createAbortController(this.timeoutMs);
try {
const response = await this.fetchImpl(url, {
method: "GET",
headers: this.buildHeaders("notices"),
redirect: "manual",
signal: controller.signal
});
ingestSetCookie(this.cookieJar, response.headers);
this.warmedUp = true;
} catch (cause) {
throw createNetworkError(cause, WARMUP_PATH);
} finally {
controller.cleanup();
}
}
async ensureBudget() {
if (this.callsSoFar >= this.maxCallsPerSession) {
const error = new Error(
`Court Auction call budget exceeded (${this.maxCallsPerSession} calls per session). ` +
"Reset the client or wait before retrying to avoid IP blocks."
);
error.code = "BUDGET_EXCEEDED";
throw error;
}
if (this.lastCallAt > 0) {
const elapsed = this.now() - this.lastCallAt;
const wait = jitter(this.minDelayMs, this.jitterMs) - elapsed;
if (wait > 0) {
await this.delayImpl(wait);
}
}
}
async postJson(endpointKey, body) {
const path = ENDPOINT_PATHS[endpointKey];
if (!path) {
throw new Error(`Unknown court auction endpoint: ${endpointKey}`);
}
await this.warmup();
await this.ensureBudget();
this.callsSoFar += 1;
this.lastCallAt = this.now();
const url = `${this.baseUrl}${path}`;
const controller = createAbortController(this.timeoutMs);
let response;
try {
response = await this.fetchImpl(url, {
method: "POST",
headers: this.buildHeaders(endpointKey, {
"Content-Type": "application/json; charset=UTF-8"
}),
body: JSON.stringify(body || {}),
redirect: "manual",
signal: controller.signal
});
} catch (cause) {
controller.cleanup();
throw createNetworkError(cause, path);
}
controller.cleanup();
ingestSetCookie(this.cookieJar, response.headers);
if (!response.ok) {
throw createUpstreamError(null, path, response.status);
}
let payload;
try {
payload = await response.json();
} catch (cause) {
throw createNetworkError(cause, path);
}
if (
payload &&
typeof payload === "object" &&
payload.errors &&
typeof payload.errors === "object" &&
payload.errors.errorMessage
) {
throw createUpstreamError(payload, path, response.status);
}
if (
payload &&
typeof payload === "object" &&
payload.data &&
typeof payload.data === "object" &&
payload.data.ipcheck === false
) {
throw createBlockedError(payload.message || null, payload);
}
return payload;
}
}
function createAbortController(timeoutMs) {
if (
typeof AbortController !== "function" ||
!Number.isFinite(timeoutMs) ||
timeoutMs <= 0
) {
return { signal: undefined, cleanup: () => {} };
}
const controller = new AbortController();
const handle = setTimeout(() => controller.abort(), timeoutMs);
return {
signal: controller.signal,
cleanup: () => clearTimeout(handle)
};
}
module.exports = {
CourtAuctionHttpClient,
ENDPOINT_PATHS,
WARMUP_PATH,
DEFAULT_BASE_URL,
DEFAULT_USER_AGENT,
createBlockedError,
createUpstreamError,
createNetworkError
};

View file

@ -0,0 +1,192 @@
"use strict";
const {
ENDPOINT_PATHS,
DEFAULT_BASE_URL,
DEFAULT_USER_AGENT,
createBlockedError,
createUpstreamError,
createNetworkError
} = require("./http");
const FALLBACK_MODULE_NAMES = ["rebrowser-playwright", "playwright-core", "playwright"];
const WARMUP_PATH = "/pgj/index.on?w2xPath=/pgj/ui/pgj100/PGJ143M01.xml&pgjId=143M01";
let cachedChromium = null;
async function loadChromium(loaderImpl) {
if (cachedChromium) return cachedChromium;
if (typeof loaderImpl === "function") {
cachedChromium = await loaderImpl();
return cachedChromium;
}
let lastError;
for (const moduleName of FALLBACK_MODULE_NAMES) {
try {
const mod = await import(moduleName);
const chromium = mod.chromium || (mod.default && mod.default.chromium);
if (chromium) {
cachedChromium = chromium;
return cachedChromium;
}
} catch (error) {
lastError = error;
}
}
const error = new Error(
"Court Auction playwright fallback requires one of " +
FALLBACK_MODULE_NAMES.join(", ") +
". Install with: npm install rebrowser-playwright"
);
error.code = "PLAYWRIGHT_UNAVAILABLE";
if (lastError) error.cause = lastError;
throw error;
}
function isFallbackAvailable() {
for (const moduleName of FALLBACK_MODULE_NAMES) {
try {
require.resolve(moduleName);
return true;
} catch {
// try next
}
}
return false;
}
class CourtAuctionPlaywrightClient {
constructor(options = {}) {
this.baseUrl = String(options.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
this.userAgent = options.userAgent || DEFAULT_USER_AGENT;
this.timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 30000;
this.headless = options.headless !== false;
this.loader = typeof options.chromiumLoader === "function" ? options.chromiumLoader : null;
this.browser = null;
this.context = null;
this.page = null;
this.warmedUp = false;
}
async ensureBrowser() {
if (this.page) return;
const chromium = await loadChromium(this.loader);
this.browser = await chromium.launch({ headless: this.headless });
this.context = await this.browser.newContext({
userAgent: this.userAgent,
locale: "ko-KR",
timezoneId: "Asia/Seoul",
viewport: { width: 1280, height: 900 }
});
this.page = await this.context.newPage();
}
async warmup() {
if (this.warmedUp) return;
await this.ensureBrowser();
try {
await this.page.goto(`${this.baseUrl}${WARMUP_PATH}`, {
waitUntil: "domcontentloaded",
timeout: this.timeoutMs
});
this.warmedUp = true;
} catch (cause) {
throw createNetworkError(cause, WARMUP_PATH);
}
}
async postJson(endpointKey, body) {
const path = ENDPOINT_PATHS[endpointKey];
if (!path) {
throw new Error(`Unknown court auction endpoint: ${endpointKey}`);
}
await this.warmup();
const url = `${this.baseUrl}${path}`;
const requestPayload = JSON.stringify(body || {});
let response;
try {
response = await this.page.evaluate(
async ({ targetUrl, payload }) => {
const res = await fetch(targetUrl, {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json; charset=UTF-8",
Accept: "application/json, text/javascript, */*; q=0.01",
"X-Requested-With": "XMLHttpRequest"
},
body: payload
});
const text = await res.text();
return { status: res.status, body: text };
},
{ targetUrl: url, payload: requestPayload }
);
} catch (cause) {
throw createNetworkError(cause, path);
}
if (!response || response.status >= 400) {
throw createUpstreamError(null, path, response ? response.status : null);
}
let payload;
try {
payload = JSON.parse(response.body);
} catch (cause) {
throw createNetworkError(cause, path);
}
if (
payload &&
payload.errors &&
typeof payload.errors === "object" &&
payload.errors.errorMessage
) {
throw createUpstreamError(payload, path, response.status);
}
if (
payload &&
payload.data &&
typeof payload.data === "object" &&
payload.data.ipcheck === false
) {
throw createBlockedError(payload.message || null, payload);
}
return payload;
}
async close() {
try {
if (this.page) await this.page.close();
} catch {
/* ignore */
}
try {
if (this.context) await this.context.close();
} catch {
/* ignore */
}
try {
if (this.browser) await this.browser.close();
} catch {
/* ignore */
}
this.page = null;
this.context = null;
this.browser = null;
this.warmedUp = false;
}
}
module.exports = {
CourtAuctionPlaywrightClient,
isFallbackAvailable,
loadChromium
};

View file

@ -0,0 +1,89 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { spawnSync } = require("node:child_process");
const path = require("node:path");
const { parseArgs, USAGE, main } = require("../src/cli");
const binPath = path.join(__dirname, "..", "bin", "court-auction-notice-search.js");
test("parseArgs handles --key value, --key=value, and -h", () => {
const result = parseArgs([
"notices",
"--date",
"2026-04-27",
"--court-code=B000210",
"--bid-type",
"date",
"--pretty",
"-h"
]);
assert.deepEqual(result._, ["notices"]);
assert.equal(result.flags.date, "2026-04-27");
assert.equal(result.flags["court-code"], "B000210");
assert.equal(result.flags["bid-type"], "date");
assert.equal(result.flags.pretty, true);
assert.equal(result.flags.help, true);
});
test("USAGE describes the four subcommands", () => {
assert.match(USAGE, /notices --date/);
assert.match(USAGE, /notice-detail/);
assert.match(USAGE, /case --court-code/);
assert.match(USAGE, /codes courts/);
assert.match(USAGE, /codes bid-types/);
assert.match(USAGE, /BLOCKED/);
});
test("main returns 0 and prints help when invoked with no args", async () => {
const writes = [];
const originalWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = (chunk) => {
writes.push(String(chunk));
return true;
};
try {
const code = await main([]);
assert.equal(code, 0);
} finally {
process.stdout.write = originalWrite;
}
assert.match(writes.join(""), /USAGE/);
});
test("CLI codes bid-types subcommand returns 기일입찰 + 기간입찰 from the static codetable", () => {
const result = spawnSync(process.execPath, [binPath, "codes", "bid-types", "--pretty"], {
encoding: "utf8"
});
assert.equal(result.status, 0, `stderr: ${result.stderr}`);
const parsed = JSON.parse(result.stdout);
assert.equal(parsed.items.length, 2);
assert.deepEqual(
parsed.items.map((item) => item.code).sort(),
["000331", "000332"]
);
});
test("CLI rejects --date with an obviously invalid format", () => {
const result = spawnSync(
process.execPath,
[binPath, "notices", "--date", "not-a-date"],
{ encoding: "utf8" }
);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /must be YYYY-MM-DD or YYYYMMDD/);
});
test("CLI prints usage and exits non-zero on unknown command", () => {
const result = spawnSync(process.execPath, [binPath, "bogus-command"], { encoding: "utf8" });
assert.notEqual(result.status, 0);
assert.match(result.stderr, /Unknown command/);
});
test("CLI -h prints help with exit 0", () => {
const result = spawnSync(process.execPath, [binPath, "-h"], { encoding: "utf8" });
assert.equal(result.status, 0);
assert.match(result.stdout, /USAGE/);
});

View file

@ -0,0 +1,10 @@
{
"status": 200,
"message": "비정상 접근이 감지되어 차단되었습니다. 1시간 후 다시 시도해주세요.",
"timestamp": 1777447035364,
"errors": null,
"data": {
"ipcheck": false
},
"token": null
}

View file

@ -0,0 +1,88 @@
{
"status": 200,
"message": "정상",
"timestamp": 1777447400000,
"errors": null,
"data": {
"ipcheck": true,
"dma_csBasInf": {
"cortOfcCd": "B000210",
"cortOfcNm": "서울중앙지방법원",
"cortSptNm": "서울중앙지방법원",
"csNo": "2024타경100001",
"csNm": "부동산임의경매",
"csRcptYmd": "20240315",
"csCmdcYmd": "20240320",
"clmAmt": "500000000",
"rletApalYn": "N",
"auctnSuspStatCd": "",
"ultmtDvsCd": "",
"csUltmtYmd": "",
"csProgStatCd": "01",
"jdgeAojAsstnNm": "홍길동",
"auctnDpcnMrgDvsCd": "",
"csProgSuspRsn": "",
"jdbnCd": "ENC_jdbn1",
"cortAuctnJdbnNm": "경매1계",
"jdbnTelno": "02-530-1234",
"execrCsTelno": "02-530-9999",
"cortTypCd": "B",
"expCsNo": "",
"lwstDvsCd": "",
"userCsNo": "2024타경100001",
"mvprpCsNo": "",
"mvprpRletDvsCd": "1"
},
"dlt_rletCsDspslObjctLst": [
{
"dspslObjctSeq": "1",
"userSt": "서울특별시 강남구 역삼동 123-4 OO아파트 101동 502호",
"userLstprdYmd": "20240615",
"cortOfcCd": "B000210",
"csNo": "2024타경100001"
}
],
"dlt_rletCsGdsDtsDxdyInf": [
{
"dspslGdsSeq": "1",
"dspslDxdyYmd": "20260427",
"lwsDspslPrc": "1200000000",
"aeeEvlAmt": "1500000000",
"rsltCd": "유찰",
"dxdySeq": "1"
},
{
"dspslGdsSeq": "1",
"dspslDxdyYmd": "20260601",
"lwsDspslPrc": "960000000",
"aeeEvlAmt": "1500000000",
"rsltCd": "",
"dxdySeq": "2"
}
],
"dlt_dstrtDemnLstprdDts": [
{
"dstrtDemnLstprdYmd": "20240615",
"dstrtDemnLstprdPbancYmd": "20240501"
}
],
"dlt_rletReltCsLst": [],
"dlt_csApalRaplDts": [],
"dlt_rletCsIntrpsLst": [],
"dlt_rletCsSugtExclBldLst": [],
"dlt_dpcnMrgTrnscsCsRlet": [],
"dlt_dspslGdsDspslObjctLst": [
{
"dspslGdsSeq": "1",
"dspslObjctSeq": "1"
}
],
"dma_dlvrfDtsParam": {
"cortCd": "B000210",
"csYear": "2024",
"csDvsCd": "타경",
"csSerial": "100001"
}
},
"token": null
}

View file

@ -0,0 +1,21 @@
{
"status": 204,
"message": "조회 되는 사건번호 정보가 없습니다.",
"timestamp": 1777447297479,
"errors": null,
"data": {
"ipcheck": true,
"dlt_dpcnMrgTrnscsCsRlet": null,
"dma_csBasInf": null,
"dlt_dstrtDemnLstprdDts": null,
"dlt_csApalRaplDts": null,
"dlt_rletReltCsLst": null,
"dlt_dspslGdsDspslObjctLst": null,
"dlt_rletCsSugtExclBldLst": null,
"dlt_rletCsGdsDtsDxdyInf": null,
"dlt_rletCsDspslObjctLst": null,
"dlt_rletCsIntrpsLst": null,
"dma_dlvrfDtsParam": null
},
"token": null
}

View file

@ -0,0 +1,20 @@
{
"status": 200,
"message": "정상",
"timestamp": 1777447077355,
"errors": null,
"data": {
"result": [
{ "cortOfcCd": "B000210", "cortOfcNm": "서울중앙지방법원", "cortSptNm": "서울중앙지방법원" },
{ "cortOfcCd": "B000211", "cortOfcNm": "서울동부지방법원", "cortSptNm": "서울동부지방법원" },
{ "cortOfcCd": "B000215", "cortOfcNm": "서울서부지방법원", "cortSptNm": "서울서부지방법원" },
{ "cortOfcCd": "B000212", "cortOfcNm": "서울남부지방법원", "cortSptNm": "서울남부지방법원" },
{ "cortOfcCd": "B000213", "cortOfcNm": "서울북부지방법원", "cortSptNm": "서울북부지방법원" },
{ "cortOfcCd": "B000214", "cortOfcNm": "의정부지방법원", "cortSptNm": "의정부지방법원" },
{ "cortOfcCd": "B214807", "cortOfcNm": "고양지원", "cortSptNm": "고양지원" },
{ "cortOfcCd": "B000240", "cortOfcNm": "인천지방법원", "cortSptNm": "인천지방법원" },
{ "cortOfcCd": "B000250", "cortOfcNm": "수원지방법원", "cortSptNm": "수원지방법원" }
]
},
"token": null
}

View file

@ -0,0 +1,8 @@
{
"timestamp": 1777447175663,
"errors": {
"errorMessage": "사용에 불편을 드려서 죄송합니다. 잠시 후 다시 이용해 주십시오. (동일한 상황이 지속되는 경우 사용자지원센터로 문의주시기 바랍니다.)",
"errorCode": "",
"referedUrl": "/pgj/pgj143/selectRletDspslPbancDtl.on"
}
}

View file

@ -0,0 +1,45 @@
{
"status": 200,
"message": "정상",
"timestamp": 1777447040000,
"errors": null,
"data": {
"ipcheck": true,
"dma_srchGnrlPbanc": {
"cortOfcCd": "B000210",
"dspslDxdyYmd": "20260427",
"bidBgngYmd": "20260427",
"bidEndYmd": "20260427",
"jdbnCd": "ENC_jdbn1",
"cortAuctnJdbnNm": "경매1계",
"jdbnTelno": "02-530-1234",
"dspslPlcNm": "서울중앙지방법원 경매법정 (4별관 211호)",
"fstDspslHm": "1000",
"scndDspslHm": "1400",
"thrdDspslHm": "",
"fothDspslHm": "",
"bidDvsCd": "000331"
},
"dlt_gnrlPbancLst": [
{
"csNo": "2024타경100001",
"dspslSeq": "1",
"usgNm": "아파트",
"st": "서울특별시 강남구 역삼동 123-4 OO아파트 101동 502호",
"aeeEvlAmt": "1500000000",
"dspslRmk": "토지·건물 일괄매각",
"lwsDspslPrc": "1200000000"
},
{
"csNo": "2024타경100002",
"dspslSeq": "1",
"usgNm": "근린생활시설",
"st": "서울특별시 강남구 삼성동 555-6",
"aeeEvlAmt": "850000000",
"dspslRmk": "",
"lwsDspslPrc": "680000000"
}
]
},
"token": null
}

View file

@ -0,0 +1,11 @@
{
"status": 200,
"message": "배당요구종기공고",
"timestamp": 1777447035364,
"errors": null,
"data": {
"ipcheck": true,
"dlt_rletDspslPbancLst": []
},
"token": null
}

View file

@ -0,0 +1,60 @@
{
"status": 200,
"message": "정상",
"timestamp": 1777447035364,
"errors": null,
"data": {
"ipcheck": true,
"dlt_rletDspslPbancLst": [
{
"realBidPerd": "",
"dspslDxdyYmd": "20260427",
"cortSptNm": "서울중앙지방법원",
"cortAuctnJdbnNm": "경매1계",
"cortOfcCd": "B000210",
"cortOfcNm": "서울중앙지방법원",
"jdbnCd": "ENC_jdbn1",
"jdbnTelno": "02-530-1234",
"bidDvsCd": "000331",
"bidBgngYmd": "20260427",
"bidEndYmd": "20260427",
"dspslPlcNm": "서울중앙지방법원 경매법정 (4별관 211호)",
"fstDspslHm": "1000",
"scndDspslHm": "1400",
"thrdDspslHm": "",
"fothDspslHm": "",
"corCnt": "0",
"canCnt": "0",
"dspslRealId": "REAL_ID_2026042701",
"printJdbnNm": "경매1계",
"intgCd": "000331",
"intgCdNm": "기일입찰"
},
{
"realBidPerd": "20260420 ~ 20260424",
"dspslDxdyYmd": "20260427",
"cortSptNm": "서울중앙지방법원",
"cortAuctnJdbnNm": "경매2계",
"cortOfcCd": "B000210",
"cortOfcNm": "서울중앙지방법원",
"jdbnCd": "ENC_jdbn2",
"jdbnTelno": "02-530-5678",
"bidDvsCd": "000332",
"bidBgngYmd": "20260420",
"bidEndYmd": "20260424",
"dspslPlcNm": "서울중앙지방법원 경매법정",
"fstDspslHm": "1000",
"scndDspslHm": "",
"thrdDspslHm": "",
"fothDspslHm": "",
"corCnt": "0",
"canCnt": "0",
"dspslRealId": "REAL_ID_2026042702",
"printJdbnNm": "경매2계",
"intgCd": "000332",
"intgCdNm": "기간입찰"
}
]
},
"token": null
}

View file

@ -0,0 +1,245 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const {
searchSaleNotices,
getSaleNoticeDetail,
getCaseByCaseNumber,
getCourtCodes,
getBidTypes,
resolveBidTypeCode,
describeBidTypeCode,
buildNoticeDetailBody,
ENDPOINT_PATHS,
CourtAuctionHttpClient,
isPlaywrightFallbackAvailable
} = require("../src/index");
const fixturesDir = path.join(__dirname, "fixtures");
function loadFixture(name) {
return JSON.parse(fs.readFileSync(path.join(fixturesDir, name), "utf8"));
}
function makeFakeClient(handler) {
const calls = [];
return {
calls,
async postJson(endpointKey, body) {
calls.push({ endpointKey, body });
return handler(endpointKey, body);
}
};
}
test("getBidTypes returns 기일입찰 + 기간입찰", () => {
const types = getBidTypes();
assert.equal(types.length, 2);
assert.deepEqual(
types.map((t) => t.code).sort(),
["000331", "000332"]
);
assert.deepEqual(
types.map((t) => t.name).sort(),
["기간입찰", "기일입찰"]
);
});
test("resolveBidTypeCode accepts alias / code / korean name and is fail-open", () => {
assert.equal(resolveBidTypeCode("date"), "000331");
assert.equal(resolveBidTypeCode("DATE"), "000331");
assert.equal(resolveBidTypeCode("period"), "000332");
assert.equal(resolveBidTypeCode("기일입찰"), "000331");
assert.equal(resolveBidTypeCode("기간입찰"), "000332");
assert.equal(resolveBidTypeCode("000331"), "000331");
assert.equal(resolveBidTypeCode(""), "");
assert.equal(resolveBidTypeCode(undefined), "");
assert.equal(resolveBidTypeCode("000999"), "000999");
});
test("describeBidTypeCode returns the Korean name", () => {
assert.equal(describeBidTypeCode("000331"), "기일입찰");
assert.equal(describeBidTypeCode("000332"), "기간입찰");
assert.equal(describeBidTypeCode("UNKNOWN"), "UNKNOWN");
assert.equal(describeBidTypeCode(""), "");
});
test("searchSaleNotices builds the expected request body and normalizes the response", async () => {
const client = makeFakeClient((endpoint) => {
assert.equal(endpoint, "notices");
return loadFixture("notices-sample.json");
});
const result = await searchSaleNotices({
date: "2026-04-27",
courtCode: "B000210",
bidType: "date",
client
});
assert.equal(client.calls.length, 1);
assert.deepEqual(client.calls[0].body, {
dma_srchDspslPbanc: {
srchYmd: "20260427",
cortOfcCd: "B000210",
bidDvsCd: "000331",
srchBtnYn: "Y"
}
});
assert.equal(result.count, 2);
assert.equal(result.requestedDate, "2026-04-27");
assert.equal(result.requestedCourtCode, "B000210");
assert.deepEqual(result.requestedBidType, { code: "000331", name: "기일입찰" });
assert.equal(result.items[0].caseNumber, undefined);
assert.equal(result.items[0].noticeId, "REAL_ID_2026042701");
});
test("searchSaleNotices accepts compact YYYYMMDD dates and rejects garbage", async () => {
const client = makeFakeClient(() => loadFixture("notices-empty.json"));
await searchSaleNotices({ date: "20260427", client });
assert.equal(client.calls[0].body.dma_srchDspslPbanc.srchYmd, "20260427");
await assert.rejects(
() => searchSaleNotices({ date: "not-a-date", client }),
/must be YYYY-MM-DD or YYYYMMDD/
);
});
test("searchSaleNotices rejects an obviously bad courtCode", async () => {
const client = makeFakeClient(() => loadFixture("notices-empty.json"));
await assert.rejects(
() => searchSaleNotices({ date: "2026-04-27", courtCode: "INVALID", client }),
/courtCode must look like/
);
});
test("buildNoticeDetailBody requires courtCode + saleDate + jdbnCd", () => {
assert.throws(() => buildNoticeDetailBody({}), /requires courtCode/);
assert.throws(
() => buildNoticeDetailBody({ courtCode: "B000210" }),
/requires saleDate/
);
assert.throws(
() => buildNoticeDetailBody({ courtCode: "B000210", saleDate: "2026-04-27" }),
/requires judgeDeptCode/
);
});
test("buildNoticeDetailBody round-trips a row from the list response (raw passthrough)", () => {
const list = loadFixture("notices-sample.json").data.dlt_rletDspslPbancLst;
const noticeRow = list[0];
const body = buildNoticeDetailBody({ raw: noticeRow });
assert.deepEqual(body, {
dma_srchGnrlPbanc: {
cortOfcCd: "B000210",
dspslDxdyYmd: "20260427",
bidBgngYmd: "20260427",
bidEndYmd: "20260427",
jdbnCd: "ENC_jdbn1",
cortAuctnJdbnNm: "경매1계",
jdbnTelno: "02-530-1234",
dspslPlcNm: "서울중앙지방법원 경매법정 (4별관 211호)",
fstDspslHm: "1000",
scndDspslHm: "1400",
thrdDspslHm: "",
fothDspslHm: "",
bidDvsCd: "000331"
}
});
});
test("getSaleNoticeDetail issues noticeDetail POST and normalizes 사건번호/용도/주소/가격", async () => {
const client = makeFakeClient((endpoint) => {
assert.equal(endpoint, "noticeDetail");
return loadFixture("notice-detail-sample.json");
});
const list = loadFixture("notices-sample.json").data.dlt_rletDspslPbancLst;
const result = await getSaleNoticeDetail(
{ raw: list[0] },
{ client }
);
assert.equal(result.count, 2);
const first = result.items[0];
assert.equal(first.caseNumber, "2024타경100001");
assert.equal(first.usage, "아파트");
assert.equal(first.appraisedPrice, 1500000000);
assert.equal(first.minimumSalePrice, 1200000000);
assert.equal(result.notice.salePlace, "서울중앙지방법원 경매법정 (4별관 211호)");
});
test("getCaseByCaseNumber sends {cortOfcCd, csNo} and returns normalized case info when found", async () => {
const client = makeFakeClient((endpoint, body) => {
assert.equal(endpoint, "caseDetail");
assert.deepEqual(body, {
dma_srchCsDtlInf: {
cortOfcCd: "B000210",
csNo: "2024타경100001"
}
});
return loadFixture("case-found-sample.json");
});
const result = await getCaseByCaseNumber({
courtCode: "B000210",
caseNumber: "2024타경100001",
client
});
assert.equal(result.found, true);
assert.equal(result.caseInfo.caseName, "부동산임의경매");
assert.equal(result.schedule.length, 2);
});
test("getCaseByCaseNumber tolerates 2024-100001 alternate format", async () => {
const client = makeFakeClient(() => loadFixture("case-not-found.json"));
await getCaseByCaseNumber({
courtCode: "B000210",
caseNumber: "2024-100001",
client
});
assert.equal(client.calls[0].body.dma_srchCsDtlInf.csNo, "2024타경100001");
});
test("getCaseByCaseNumber returns found:false on status 204", async () => {
const client = makeFakeClient(() => loadFixture("case-not-found.json"));
const result = await getCaseByCaseNumber({
courtCode: "B000210",
caseNumber: "2024타경999999",
client
});
assert.equal(result.found, false);
assert.equal(result.status, 204);
assert.match(result.message, /조회 되는 사건번호 정보가 없습니다/);
});
test("getCourtCodes hits the courts endpoint and returns code/name pairs", async () => {
const client = makeFakeClient((endpoint) => {
assert.equal(endpoint, "courts");
return loadFixture("courts-sample.json");
});
const result = await getCourtCodes({ client });
assert.equal(result.count, 9);
assert.equal(result.items[0].code, "B000210");
assert.equal(result.items[0].name, "서울중앙지방법원");
});
test("ENDPOINT_PATHS exposes the discovered courtauction.go.kr endpoints", () => {
assert.equal(ENDPOINT_PATHS.notices, "/pgj/pgj143/selectRletDspslPbanc.on");
assert.equal(ENDPOINT_PATHS.noticeDetail, "/pgj/pgj143/selectRletDspslPbancDtl.on");
assert.equal(ENDPOINT_PATHS.caseDetail, "/pgj/pgj15A/selectAuctnCsSrchRslt.on");
assert.equal(ENDPOINT_PATHS.courts, "/pgj/pgjComm/selectCortOfcCdLst.on");
});
test("isPlaywrightFallbackAvailable is a boolean (no crash even when modules are absent)", () => {
const result = isPlaywrightFallbackAvailable();
assert.equal(typeof result, "boolean");
});
test("CourtAuctionHttpClient is exported for advanced clients to override transport", () => {
assert.equal(typeof CourtAuctionHttpClient, "function");
});

View file

@ -0,0 +1,184 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const {
normalizeNoticeListResponse,
normalizeNoticeDetailResponse,
normalizeCaseDetailResponse,
normalizeCourtCodesResponse,
parseAmount,
formatYmd,
formatHm
} = require("../src/normalize");
const fixturesDir = path.join(__dirname, "fixtures");
function loadFixture(name) {
return JSON.parse(fs.readFileSync(path.join(fixturesDir, name), "utf8"));
}
const noticesEmpty = loadFixture("notices-empty.json");
const noticesSample = loadFixture("notices-sample.json");
const noticeDetailSample = loadFixture("notice-detail-sample.json");
const caseFoundSample = loadFixture("case-found-sample.json");
const caseNotFoundSample = loadFixture("case-not-found.json");
const courtsSample = loadFixture("courts-sample.json");
test("parseAmount strips commas/won/spaces and rejects non-numeric", () => {
assert.equal(parseAmount("1,500,000,000"), 1500000000);
assert.equal(parseAmount("1,500,000,000원"), 1500000000);
assert.equal(parseAmount(" 850000000 "), 850000000);
assert.equal(parseAmount(""), null);
assert.equal(parseAmount("-"), null);
assert.equal(parseAmount("not-a-number"), null);
assert.equal(parseAmount(null), null);
assert.equal(parseAmount(undefined), null);
});
test("formatYmd inserts hyphens for 8-digit dates and passes through otherwise", () => {
assert.equal(formatYmd("20260427"), "2026-04-27");
assert.equal(formatYmd("2026.04.27"), "2026.04.27");
assert.equal(formatYmd(""), null);
assert.equal(formatYmd(null), null);
});
test("formatHm formats 3-4 digit times into HH:MM", () => {
assert.equal(formatHm("1000"), "10:00");
assert.equal(formatHm("930"), "09:30");
assert.equal(formatHm(""), null);
assert.equal(formatHm(null), null);
});
test("normalizeNoticeListResponse returns 0 items for an empty payload", () => {
const result = normalizeNoticeListResponse(noticesEmpty, {
requestedDate: "2026-04-30",
requestedCourtCode: "",
requestedBidType: null
});
assert.equal(result.count, 0);
assert.deepEqual(result.items, []);
assert.equal(result.requestedDate, "2026-04-30");
});
test("normalizeNoticeListResponse normalizes auction notice rows with English keys + raw passthrough", () => {
const result = normalizeNoticeListResponse(noticesSample);
assert.equal(result.count, 2);
const first = result.items[0];
assert.equal(first.noticeId, "REAL_ID_2026042701");
assert.equal(first.courtCode, "B000210");
assert.equal(first.courtName, "서울중앙지방법원");
assert.equal(first.judgeDeptCode, "ENC_jdbn1");
assert.equal(first.judgeDeptName, "경매1계");
assert.equal(first.bidTypeCode, "000331");
assert.equal(first.bidTypeName, "기일입찰");
assert.equal(first.saleDate, "2026-04-27");
assert.equal(first.bidStartDate, "2026-04-27");
assert.equal(first.bidEndDate, "2026-04-27");
assert.equal(first.salePlace, "서울중앙지방법원 경매법정 (4별관 211호)");
assert.deepEqual(first.saleTimes, ["10:00", "14:00"]);
assert.equal(first.correctionCount, 0);
assert.equal(first.cancellationCount, 0);
assert.equal(first.raw.dspslRealId, "REAL_ID_2026042701");
const second = result.items[1];
assert.equal(second.bidTypeCode, "000332");
assert.equal(second.bidTypeName, "기간입찰");
assert.equal(second.bidStartDate, "2026-04-20");
assert.equal(second.bidEndDate, "2026-04-24");
assert.equal(second.bidPeriodLabel, "20260420 ~ 20260424");
});
test("normalizeNoticeListResponse can strip raw passthrough on demand", () => {
const result = normalizeNoticeListResponse(noticesSample, { includeRaw: false });
for (const item of result.items) {
assert.equal(item.raw, undefined);
}
});
test("normalizeNoticeDetailResponse extracts 사건번호/용도/주소/감정평가/최저매각가/매각장소", () => {
const result = normalizeNoticeDetailResponse(noticeDetailSample);
assert.equal(result.count, 2);
assert.equal(result.notice.salePlace, "서울중앙지방법원 경매법정 (4별관 211호)");
assert.equal(result.notice.saleDate, "2026-04-27");
assert.equal(result.notice.bidTypeCode, "000331");
assert.equal(result.notice.bidTypeName, "기일입찰");
const first = result.items[0];
assert.equal(first.caseNumber, "2024타경100001");
assert.equal(first.itemSeq, "1");
assert.equal(first.usage, "아파트");
assert.equal(
first.address,
"서울특별시 강남구 역삼동 123-4 OO아파트 101동 502호"
);
assert.equal(first.appraisedPrice, 1500000000);
assert.equal(first.minimumSalePrice, 1200000000);
assert.equal(first.remarks, "토지·건물 일괄매각");
assert.equal(first.raw.csNo, "2024타경100001");
});
test("normalizeNoticeDetailResponse handles empty payload gracefully", () => {
const result = normalizeNoticeDetailResponse({ status: 200, data: {} });
assert.equal(result.count, 0);
assert.deepEqual(result.items, []);
});
test("normalizeCourtCodesResponse returns code/name/branchName triples", () => {
const result = normalizeCourtCodesResponse(courtsSample);
assert.equal(result.count, 9);
assert.equal(result.items[0].code, "B000210");
assert.equal(result.items[0].name, "서울중앙지방법원");
assert.equal(result.items[0].branchName, "서울중앙지방법원");
});
test("normalizeCaseDetailResponse marks status:204 / null dma_csBasInf as found:false", () => {
const result = normalizeCaseDetailResponse(caseNotFoundSample);
assert.equal(result.found, false);
assert.equal(result.status, 204);
assert.match(result.message, /조회 되는 사건번호 정보가 없습니다/);
assert.equal(result.caseInfo, null);
assert.deepEqual(result.items, []);
});
test("normalizeCaseDetailResponse extracts case basic info, items, schedule, claim deadline", () => {
const result = normalizeCaseDetailResponse(caseFoundSample);
assert.equal(result.found, true);
assert.equal(result.status, 200);
assert.equal(result.caseInfo.caseNumber, "2024타경100001");
assert.equal(result.caseInfo.courtCode, "B000210");
assert.equal(result.caseInfo.caseName, "부동산임의경매");
assert.equal(result.caseInfo.caseReceiptDate, "2024-03-15");
assert.equal(result.caseInfo.caseStartDate, "2024-03-20");
assert.equal(result.caseInfo.claimAmount, 500000000);
assert.equal(result.caseInfo.judgeDeptName, "경매1계");
assert.equal(result.items.length, 1);
assert.equal(
result.items[0].address,
"서울특별시 강남구 역삼동 123-4 OO아파트 101동 502호"
);
assert.equal(result.items[0].claimDeadlineDate, "2024-06-15");
assert.equal(result.schedule.length, 2);
assert.equal(result.schedule[0].saleDate, "2026-04-27");
assert.equal(result.schedule[0].minimumSalePrice, 1200000000);
assert.equal(result.schedule[0].appraisedPrice, 1500000000);
assert.equal(result.schedule[0].resultCode, "유찰");
assert.equal(result.schedule[1].minimumSalePrice, 960000000);
assert.equal(result.schedule[1].resultCode, null);
assert.deepEqual(result.claimDeadline, {
deadlineDate: "2024-06-15",
announcementDate: "2024-05-01"
});
});
test("normalizeCaseDetailResponse strips raw when includeRaw=false", () => {
const result = normalizeCaseDetailResponse(caseFoundSample, { includeRaw: false });
assert.equal(result.raw, undefined);
});

View file

@ -0,0 +1,218 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const {
CourtAuctionHttpClient,
ENDPOINT_PATHS,
WARMUP_PATH,
createBlockedError,
createUpstreamError
} = require("../src/transport/http");
const fixturesDir = path.join(__dirname, "fixtures");
function loadFixture(name) {
return JSON.parse(fs.readFileSync(path.join(fixturesDir, name), "utf8"));
}
const noticesSample = loadFixture("notices-sample.json");
const blockedSample = loadFixture("blocked.json");
const errorSample = loadFixture("error-response.json");
function makeJsonResponse(body, headers = {}, status = 200) {
const responseHeaders = new Headers({ "content-type": "application/json", ...headers });
return {
ok: status >= 200 && status < 300,
status,
headers: {
get: (name) => responseHeaders.get(name),
getSetCookie: () => {
const value = headers["set-cookie"];
if (!value) return [];
return Array.isArray(value) ? value : [value];
}
},
json: async () => body
};
}
function buildFakeFetch(handlers) {
const calls = [];
const fetchImpl = async (url, init = {}) => {
calls.push({ url: String(url), init });
const handler = handlers[String(url).split("?")[0].replace(/^https?:\/\/[^/]+/, "")];
if (typeof handler === "function") {
return handler(url, init);
}
if (handler !== undefined) return handler;
throw new Error(`unmocked URL: ${url}`);
};
fetchImpl.calls = calls;
return fetchImpl;
}
function newClient(handlers, overrides = {}) {
return new CourtAuctionHttpClient({
fetchImpl: buildFakeFetch(handlers),
minDelayMs: 0,
jitterMs: 0,
timeoutMs: 5000,
delayImpl: async () => {},
now: (() => {
let t = 1_000_000;
return () => {
t += 1;
return t;
};
})(),
...overrides
});
}
test("warmup GETs the index page and stores JSESSIONID/WMONID cookies", async () => {
const client = newClient({
[WARMUP_PATH.split("?")[0]]: () =>
makeJsonResponse(
{},
{
"set-cookie": [
"JSESSIONID=abc123; Path=/; HttpOnly",
"WMONID=def456; Path=/"
]
}
)
});
await client.warmup();
assert.equal(client.warmedUp, true);
assert.equal(client.cookieJar.get("JSESSIONID"), "abc123");
assert.equal(client.cookieJar.get("WMONID"), "def456");
});
test("postJson calls warmup first, then POSTs body with cookies + correct headers", async () => {
const fetchImpl = buildFakeFetch({
[WARMUP_PATH.split("?")[0]]: () =>
makeJsonResponse(
{},
{ "set-cookie": "JSESSIONID=session1; Path=/" }
),
[ENDPOINT_PATHS.notices]: () => makeJsonResponse(noticesSample)
});
const client = new CourtAuctionHttpClient({
fetchImpl,
minDelayMs: 0,
jitterMs: 0,
delayImpl: async () => {}
});
const payload = await client.postJson("notices", {
dma_srchDspslPbanc: { srchYmd: "20260427", cortOfcCd: "", bidDvsCd: "", srchBtnYn: "Y" }
});
assert.equal(payload.status, 200);
assert.equal(fetchImpl.calls.length, 2);
assert.equal(fetchImpl.calls[0].init.method, "GET");
assert.equal(fetchImpl.calls[1].init.method, "POST");
const postHeaders = fetchImpl.calls[1].init.headers;
assert.equal(postHeaders["Content-Type"], "application/json; charset=UTF-8");
assert.equal(postHeaders["X-Requested-With"], "XMLHttpRequest");
assert.match(postHeaders.Cookie, /JSESSIONID=session1/);
assert.equal(postHeaders.Origin, "https://www.courtauction.go.kr");
});
test("postJson throws BLOCKED error when data.ipcheck === false", async () => {
const client = newClient({
[WARMUP_PATH.split("?")[0]]: () => makeJsonResponse({}),
[ENDPOINT_PATHS.notices]: () => makeJsonResponse(blockedSample)
});
await assert.rejects(
() =>
client.postJson("notices", {
dma_srchDspslPbanc: { srchYmd: "20260427", cortOfcCd: "", bidDvsCd: "", srchBtnYn: "Y" }
}),
(error) => {
assert.equal(error.code, "BLOCKED");
assert.match(error.message, /blocked|차단/);
assert.equal(error.upstreamPayload.message, blockedSample.message);
return true;
}
);
});
test("postJson throws UPSTREAM_ERROR when payload.errors.errorMessage is set", async () => {
const client = newClient({
[WARMUP_PATH.split("?")[0]]: () => makeJsonResponse({}),
[ENDPOINT_PATHS.noticeDetail]: () => makeJsonResponse(errorSample)
});
await assert.rejects(
() => client.postJson("noticeDetail", {}),
(error) => {
assert.equal(error.code, "UPSTREAM_ERROR");
assert.match(error.upstreamMessage, /사용에 불편을 드려/);
return true;
}
);
});
test("postJson enforces a per-session call budget", async () => {
const client = newClient(
{
[WARMUP_PATH.split("?")[0]]: () => makeJsonResponse({}),
[ENDPOINT_PATHS.notices]: () => makeJsonResponse(noticesSample)
},
{ maxCallsPerSession: 2 }
);
await client.postJson("notices", {});
await client.postJson("notices", {});
await assert.rejects(() => client.postJson("notices", {}), (err) => {
assert.equal(err.code, "BUDGET_EXCEEDED");
return true;
});
});
test("postJson throttles between calls using delayImpl", async () => {
const delays = [];
let now = 1000;
const client = new CourtAuctionHttpClient({
fetchImpl: buildFakeFetch({
[WARMUP_PATH.split("?")[0]]: () => makeJsonResponse({}),
[ENDPOINT_PATHS.notices]: () => makeJsonResponse(noticesSample)
}),
minDelayMs: 1500,
jitterMs: 0,
delayImpl: async (ms) => {
delays.push(ms);
},
now: () => now
});
await client.postJson("notices", {});
await client.postJson("notices", {});
assert.ok(
delays.some((d) => d === 1500),
`expected a 1500ms throttle delay, got [${delays.join(",")}]`
);
});
test("createBlockedError and createUpstreamError carry diagnostics", () => {
const blocked = createBlockedError(null, { message: "차단" });
assert.equal(blocked.code, "BLOCKED");
assert.equal(blocked.upstreamMessage, "차단");
const upstream = createUpstreamError(
{ errors: { errorMessage: "boom" } },
"/pgj/x.on",
500
);
assert.equal(upstream.code, "UPSTREAM_ERROR");
assert.equal(upstream.statusCode, 500);
assert.equal(upstream.upstreamMessage, "boom");
});