mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
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:
parent
f79308acfc
commit
d11c7d37bf
31 changed files with 2873 additions and 2 deletions
5
.changeset/court-auction-notice-search.md
Normal file
5
.changeset/court-auction-notice-search.md
Normal 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`.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
173
court-auction-notice-search/SKILL.md
Normal file
173
court-auction-notice-search/SKILL.md
Normal 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 이 남아있는지 사용자에게 알려서 추가 호출 여지를 명시했다.
|
||||
96
docs/features/court-auction-notice-search.md
Normal file
96
docs/features/court-auction-notice-search.md
Normal 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 이슈로 분리되어 추적됩니다.
|
||||
|
|
@ -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)"
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
- 한국 개인정보처리방침·이용약관 스킬 출시 (kimlawtech/korean-privacy-terms Apache-2.0 업스트림 기반 thin wrapper)
|
||||
- 한국 부동산 실거래가 조회 스킬 출시
|
||||
- LH 청약 공고문 조회 스킬 출시
|
||||
- 법원 경매 부동산 매각공고 조회 스킬 출시 (court-auction-notice-search v1: 매각공고+사건번호 직조회 MVP)
|
||||
- 의약품 안전 체크 스킬 출시
|
||||
- 식품 안전 체크 스킬 출시
|
||||
- 장학금 검색 및 조회 스킬 출시
|
||||
|
|
|
|||
|
|
@ -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
20
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
7
packages/court-auction-notice-search/CHANGELOG.md
Normal file
7
packages/court-auction-notice-search/CHANGELOG.md
Normal 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 이슈로 분리.
|
||||
150
packages/court-auction-notice-search/README.md
Normal file
150
packages/court-auction-notice-search/README.md
Normal 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. 사이트 운영 정책을 준수해 주세요.
|
||||
19
packages/court-auction-notice-search/bin/court-auction-notice-search.js
Executable file
19
packages/court-auction-notice-search/bin/court-auction-notice-search.js
Executable 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);
|
||||
}
|
||||
);
|
||||
41
packages/court-auction-notice-search/package.json
Normal file
41
packages/court-auction-notice-search/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
203
packages/court-auction-notice-search/src/cli.js
Normal file
203
packages/court-auction-notice-search/src/cli.js
Normal 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 };
|
||||
|
|
@ -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)"
|
||||
}
|
||||
]
|
||||
}
|
||||
76
packages/court-auction-notice-search/src/codetables/index.js
Normal file
76
packages/court-auction-notice-search/src/codetables/index.js
Normal 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
|
||||
};
|
||||
243
packages/court-auction-notice-search/src/index.js
Normal file
243
packages/court-auction-notice-search/src/index.js
Normal 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
|
||||
};
|
||||
324
packages/court-auction-notice-search/src/normalize.js
Normal file
324
packages/court-auction-notice-search/src/normalize.js
Normal 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
|
||||
};
|
||||
297
packages/court-auction-notice-search/src/transport/http.js
Normal file
297
packages/court-auction-notice-search/src/transport/http.js
Normal 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
|
||||
};
|
||||
192
packages/court-auction-notice-search/src/transport/playwright.js
Normal file
192
packages/court-auction-notice-search/src/transport/playwright.js
Normal 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
|
||||
};
|
||||
89
packages/court-auction-notice-search/test/cli.test.js
Normal file
89
packages/court-auction-notice-search/test/cli.test.js
Normal 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/);
|
||||
});
|
||||
10
packages/court-auction-notice-search/test/fixtures/blocked.json
vendored
Normal file
10
packages/court-auction-notice-search/test/fixtures/blocked.json
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"status": 200,
|
||||
"message": "비정상 접근이 감지되어 차단되었습니다. 1시간 후 다시 시도해주세요.",
|
||||
"timestamp": 1777447035364,
|
||||
"errors": null,
|
||||
"data": {
|
||||
"ipcheck": false
|
||||
},
|
||||
"token": null
|
||||
}
|
||||
88
packages/court-auction-notice-search/test/fixtures/case-found-sample.json
vendored
Normal file
88
packages/court-auction-notice-search/test/fixtures/case-found-sample.json
vendored
Normal 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
|
||||
}
|
||||
21
packages/court-auction-notice-search/test/fixtures/case-not-found.json
vendored
Normal file
21
packages/court-auction-notice-search/test/fixtures/case-not-found.json
vendored
Normal 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
|
||||
}
|
||||
20
packages/court-auction-notice-search/test/fixtures/courts-sample.json
vendored
Normal file
20
packages/court-auction-notice-search/test/fixtures/courts-sample.json
vendored
Normal 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
|
||||
}
|
||||
8
packages/court-auction-notice-search/test/fixtures/error-response.json
vendored
Normal file
8
packages/court-auction-notice-search/test/fixtures/error-response.json
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"timestamp": 1777447175663,
|
||||
"errors": {
|
||||
"errorMessage": "사용에 불편을 드려서 죄송합니다. 잠시 후 다시 이용해 주십시오. (동일한 상황이 지속되는 경우 사용자지원센터로 문의주시기 바랍니다.)",
|
||||
"errorCode": "",
|
||||
"referedUrl": "/pgj/pgj143/selectRletDspslPbancDtl.on"
|
||||
}
|
||||
}
|
||||
45
packages/court-auction-notice-search/test/fixtures/notice-detail-sample.json
vendored
Normal file
45
packages/court-auction-notice-search/test/fixtures/notice-detail-sample.json
vendored
Normal 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
|
||||
}
|
||||
11
packages/court-auction-notice-search/test/fixtures/notices-empty.json
vendored
Normal file
11
packages/court-auction-notice-search/test/fixtures/notices-empty.json
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"status": 200,
|
||||
"message": "배당요구종기공고",
|
||||
"timestamp": 1777447035364,
|
||||
"errors": null,
|
||||
"data": {
|
||||
"ipcheck": true,
|
||||
"dlt_rletDspslPbancLst": []
|
||||
},
|
||||
"token": null
|
||||
}
|
||||
60
packages/court-auction-notice-search/test/fixtures/notices-sample.json
vendored
Normal file
60
packages/court-auction-notice-search/test/fixtures/notices-sample.json
vendored
Normal 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
|
||||
}
|
||||
245
packages/court-auction-notice-search/test/index.test.js
Normal file
245
packages/court-auction-notice-search/test/index.test.js
Normal 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");
|
||||
});
|
||||
184
packages/court-auction-notice-search/test/normalize.test.js
Normal file
184
packages/court-auction-notice-search/test/normalize.test.js
Normal 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);
|
||||
});
|
||||
218
packages/court-auction-notice-search/test/transport.test.js
Normal file
218
packages/court-auction-notice-search/test/transport.test.js
Normal 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");
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue