Enable property search by free auction conditions (#213)

Add Workflow C for court-auction-notice-search with direct PGJ151 property search payload mapping, representative frozen code tables, CLI/docs coverage, and normalized item rows.

Constraint: Issue #184 requires Workflow C region/usage/price/date/area/flbd filters and release automation requires a Changeset.

Rejected: Proxy route | courtauction.go.kr property search is a public site endpoint and does not require an API key.

Confidence: high

Scope-risk: moderate

Directive: Keep code-table lookups fail-open and avoid tests that pin package versions or changeset file presence.

Tested: npm test --workspace court-auction-notice-search; npm run lint --workspace court-auction-notice-search; npm run ci

Not-tested: Live courtauction.go.kr property search, to avoid unnecessary upstream calls and potential anti-bot blocking.
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-08 10:14:33 +09:00 committed by GitHub
commit f527515932
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1546 additions and 45 deletions

View file

@ -0,0 +1,18 @@
---
"court-auction-notice-search": minor
---
Add Workflow C property free-condition search via `searchProperties()` (`POST /pgj/pgjsearch/searchControllerMain.on`).
The request body matches the canonical PGJ151M01 submission captured from a real browser session — numeric `pageNo`/`pageSize`/`statNum`, full `dma_pageInfo` shape, and the upstream-correct field names (`mvprpArtclKndCd`/`mvprpAtchmPlcTypCd`, not the previously-guessed `mvprpArtclKnd`/`mvrpDspslPlcTyp`).
The static usage/region codetables come from upstream discovery captures: 4 대분류 (`10000=토지`, `20000=건물`, `30000=차량및운송장비`, `40000=기타`) plus representative mid/small classes; 19 시도 with their official codes. Sigungu/dong cascade XHRs are not reliable so callers pass raw codes (e.g. `"11680"`) directly.
`searchProperties()` automatically falls back to the Playwright client only for WAF-style raw HTTP `UPSTREAM_ERROR` 400 responses. Confirmed `BLOCKED` / `ipcheck=false` responses stop by default to avoid extending an IP block; retrying that condition requires explicit `fallbackOnBlocked:true`. Disable fallback entirely with `{ fallback: false }`.
Other fixes:
- `resolveUsageCode(name, level)` now refuses to silently return a wrong-level code for ambiguous names (e.g. `"아파트"` exists at multiple levels) — returns the input unchanged so the upstream rejects it instead of producing a wrong query.
- `resolveRegionCodes({})` no longer accidentally maps "no region" to the first row's sido.
- `flbdCount` is integer-only; `pageSize` is restricted to the observed PGJ151 dropdown values `10`/`20`/`50`/`100` to avoid unsupported upstream requests.
- Endpoint-aware HTTP/Playwright warmup (`PGJ151F00` for property search instead of `PGJ143M01`).
- CLI `search` accepts `--region 시도:시군구:읍면동` and `--usage 대:중:소` colon shorthand alongside the existing split flags.

View file

@ -1,6 +1,6 @@
---
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으로.
description: Browse 대법원경매정보(courtauction.go.kr) 부동산 매각공고 by 매각기일·법원·기일/기간 입찰, expand each notice into 사건번호·용도·주소·감정평가액·최저매각가, search property items by free conditions(지역·용도·가격·면적·유찰횟수), and look up a case directly by 법원+사건번호. Read-only, slow-by-design (~2s/call) to avoid IP blocks.
license: MIT
metadata:
category: real-estate
@ -15,7 +15,7 @@ metadata:
대한민국 법원이 운영하는 공식 **법원경매정보** 사이트(`courtauction.go.kr`) 의 매각공고와 사건정보를 에이전트가 활용할 수 있는 JSON 형태로 변환해서 돌려준다.
- 공식 OPEN API가 없어 사이트 내부의 WebSquare JSON XHR endpoint를 그대로 호출한다.
- 1차 transport 는 직접 HTTP, 차단되거나 5xx 가 떨어질 때만 Playwright fallback 으로 전환한다 (`rebrowser-playwright` 또는 `playwright-core` 가 있을 때만).
- 1차 transport 는 직접 HTTP다. Workflow C 자유검색에서 raw-HTTP WAF성 HTTP 400이 날 때만 Playwright fallback 으로 전환하며, 명시적 차단(`BLOCKED`/`ipcheck=false`)은 기본적으로 중단한다 (`rebrowser-playwright` 또는 `playwright-core` 가 있을 때만).
- 사이트는 **IP 단위 봇 차단** 이 매우 공격적이다 (16회/30초 정도면 1시간 차단). 이 패키지는 호출 간 최소 2초 jitter, 세션당 호출 budget(기본 10회), `data.ipcheck === false` 즉시 throw 로 보수적으로 동작한다.
- **참고용 도구**다. 실제 입찰 전에는 반드시 법원 원문 매각공고를 다시 확인해야 한다.
@ -26,12 +26,12 @@ metadata:
- "기일입찰 vs 기간입찰만 나눠서 보여줘"
- "이 매각공고 안의 사건번호/용도/주소/감정평가액 다 보여줘"
- "사건번호 2024타경100001 진행 상황 알려줘"
- "서울 강남구 아파트 최저가 5억 이하 유찰 1회 이상 물건 찾아줘"
- "법원사무소 코드 표 줘"
## When not to use
- 동산(자동차·중기) 경매 (이번 v1 범위 밖)
- 자유 조건검색(지역·용도·가격대·면적·유찰횟수) — Workflow C 별도 follow-up 이슈에서 다룬다
- 특정 매각기일 날짜의 모든 법원 일정을 한 번에 (Workflow D 별도 follow-up 이슈)
- 매각물건 사진(전경/개황/내부) URL 노출 (별도 follow-up 이슈)
- 매각물건명세서 / 현황조사서 / 감정평가서 PDF 다운로드 (별도 follow-up 이슈)
@ -62,6 +62,7 @@ metadata:
- `POST /pgj/pgj143/selectRletDspslPbanc.on` — 매각공고 목록
- `POST /pgj/pgj143/selectRletDspslPbancDtl.on` — 매각공고 상세 (사건/물건 펼치기)
- `POST /pgj/pgj15A/selectAuctnCsSrchRslt.on` — 사건 단건 조회
- `POST /pgj/pgjsearch/searchControllerMain.on` — 물건 자유 조건검색 (PGJ151F00 → PGJ151M01)
- `POST /pgj/pgjComm/selectCortOfcCdLst.on` — 법원사무소코드 전체
## Workflow A — 매각공고 → 사건/물건 펼치기
@ -79,12 +80,42 @@ metadata:
3. `found:false / status:204` 면 사건이 존재하지 않거나 비공개. 사건번호 형식·법원이 맞는지 사용자에게 다시 확인한다.
4. `found:true``caseInfo`(사건명·접수일·청구액·재판부·진행상태), `items[]`(매각목적물 — 주소/배당요구종기), `schedule[]`(매각기일별 최저가/감정가/결과), `claimDeadline`, `relatedCases`, `stakeholders` 가 채워진다.
## Workflow C — 부동산 물건 자유 조건검색
1. 사용자의 조건을 `searchProperties()` 입력으로 매핑한다.
- `region: { sido, sigungu, dong }` — 코드 또는 대표 정적 sido 코드테이블의 한국어명. 지역을 주면 지번주소 검색(`cortStDvs:"2"`)으로, 지역이 없으면 매각공고 모드(`cortStDvs:"1"`)로 조회한다. 시군구/읍면동은 정적 표가 없으므로 코드로 직접 전달(예: `{ sido:"11", sigungu:"11680", dong:"11680101" }`)한다.
- `usage: { large, medium, small }` — 용도 대/중/소분류 코드(5자리, 예: 건물=`20000`) 또는 대분류 한국어명(`토지`/`건물`/`차량및운송장비`/`기타`).
- `priceRange` — 최저매각가격 원 단위 `{ min, max }` (실수 허용)
- `appraisedPriceRange` — 감정평가액 원 단위 `{ min, max }` (실수 허용)
- `saleDate``{ from, to }`
- `flbdCount` — 유찰횟수 `{ min, max }` **정수만**
- `area` — 면적(㎡) `{ min, max }` (실수 허용)
- `pageSize` — 페이지당 결과 수, upstream PGJ151 드롭다운에서 확인된 `10`/`20`/`50`/`100` 중 하나(기본 10). `1` 등 임의 값은 live endpoint 가 HTTP 400을 반환하므로 로컬에서 거부한다.
2. `searchProperties({ ... })` 호출 → `POST /pgj/pgjsearch/searchControllerMain.on`.
- 1차로 direct HTTP 시도. Workflow C raw-HTTP WAF의 HTTP 400을 만나면 자동으로 Playwright fallback 으로 재시도한다. fallback 을 끄려면 `{ fallback: false }`. `BLOCKED`(`ipcheck=false`)는 사이트의 명시적 차단 신호이므로 기본적으로 즉시 중단하며, 사용자가 위험을 이해하고 명시적으로 `{ fallbackOnBlocked: true }` 를 준 경우에만 재시도한다.
3. 응답의 `items[]` 는 핵심 raw 컬럼을 영문 키로 정규화한다:
- `saNo``caseNumber`, `srnSaNo`/`printCsNo``displayCaseNumber`
- `mokmulSer`/`maemulSer``itemNumber`
- `hjguSido + hjguSigu + hjguDong + daepyoLotno + buldNm``address`
- `gamevalAmt``appraisedPrice`, `minmaePrice``minimumSalePrice`
- `yuchalCnt``flbdCount`, `mulStatcd``statusCode`, `jinstatCd``progressStatusCode`
- `boCd``courtCode`, `jiwonNm``courtName`, `jpDeptNm``judgeDeptName`
- `lclsUtilCd/mclsUtilCd/sclsUtilCd``usageCodes.{large,medium,small}`
- `srchHjguSidoCd/SiguCd/DongCd``regionCodes.{sido,sigungu,dong}`
- `xCordi/yCordi``coordinates`, `wgs84Xcordi/Ycordi``coordinatesWgs84`
- `buldList/areaList/jimokList``buildingList/areaList/landCategoryList`
- `pjbBuldList``propertyDescription`, `mulBigo``remarks`
4. `getUsageCodes()` 는 4개 대분류(`10000=토지`, `20000=건물`, `30000=차량및운송장비`, `40000=기타`)와 일부 대표 중/소분류를 정적으로 반환한다. `getRegionCodes()` 는 19개 시도 + 코드만 반환한다. 시군구/읍면동은 upstream cascade XHR이 안정적이지 않아 정적 표에 포함하지 않으며 raw 코드를 그대로 전달하면 된다. 알 수 없는 값은 fail-open으로 통과한다.
5. **Same-name usage codes 보호**: `resolveUsageCode("아파트", "large")` 처럼 입력 이름이 다른 level 에만 존재하면, 같은 이름의 medium/small 코드를 잘못 리턴하지 않고 fail-open(원문 통과)한다.
## Throttling and call-budget rules
- 호출 간 최소 2초 (기본). 더 늘리려면 `--min-delay-ms 3000`.
- 기본 세션 budget 은 **10회**. 더 많은 조회가 필요하면 새 세션을 열거나 (`new CourtAuctionHttpClient`) `maxCallsPerSession` 을 명시적으로 늘린다.
- 차단(`data.ipcheck === false`)을 만나면 `BLOCKED` 에러를 즉시 throw 하고 멈춘다. 자동 retry 하지 않는다 (차단 연장 위험).
- 차단된 IP는 **약 1시간** 후 자연 복구된다. 그 사이에는 다른 IP/네트워크에서 작업하거나 사람이 브라우저로 사이트에 접속해서 차단 해제 화면을 거친다.
- **Workflow C 자유검색은 사이트 WAF 가 raw HTTP 호출을 더 엄격하게 차단**한다. `searchProperties()` 는 1차 direct HTTP에서 WAF성 HTTP 400을 만났을 때만 Playwright fallback 으로 재시도한다. 명시적 차단(`BLOCKED`/`ipcheck=false`)은 기본적으로 즉시 중단하며, 사용자가 위험을 이해하고 `fallbackOnBlocked:true` 를 준 경우에만 재시도한다. Playwright fallback 모듈(`rebrowser-playwright` 또는 `playwright-core`)이 없으면 첫 HTTP 400 실패가 그대로 throw 된다.
- searchProperties 는 같은 Playwright 클라이언트로 연속 호출하면 **10~15회 간격 호출에서 안정**하다. 그 이상 burst 호출이 필요하면 호출 사이 3~5초 sleep 을 두고 새 클라이언트를 열어라.
## Node.js example
@ -145,6 +176,8 @@ court-auction-notice-search codes courts --pretty | head -40
# 2. 입찰구분 (정적 코드)
court-auction-notice-search codes bid-types --pretty
court-auction-notice-search codes usages --pretty
court-auction-notice-search codes regions --pretty
# 3. 매각공고 목록
court-auction-notice-search notices --date 2026-04 --court-code B000210 --bid-type date --pretty
@ -154,6 +187,10 @@ court-auction-notice-search notices --date 2026-04 --court-code B000210 --bid-ty
# 5. 사건번호 직접 조회
court-auction-notice-search case --court-code B000210 --case-number "2024타경100001" --pretty
# 6. 자유 조건검색
court-auction-notice-search search --sido 서울특별시 --sigungu 11680 --usage-large 건물 --usage-medium 21200 \
--price-min 100000000 --price-max 500000000 --sale-from 2026-05-01 --sale-to 2026-05-20 --pretty
```
## Block / Error handling

View file

@ -8,13 +8,13 @@
- ✅ Workflow A — **매각공고 브라우징**: 매각기일·법원·기일/기간 입찰을 조건으로 매각공고 목록 → 그 공고 안의 사건번호·용도·주소·감정평가액·최저매각가격 펼치기
- ✅ Workflow B — **사건번호 직접 조회**: 법원사무소코드 + 사건번호(`2024타경100001`) → 사건정보·물건내역·매각기일별 이력·배당요구종기
- ✅ 법원사무소 코드(60+개) + 입찰구분 코드(기일입찰=`000331`, 기간입찰=`000332`) 변환
- ✅ Workflow C — **부동산 물건 자유 조건검색**: 지역·용도·가격대·면적·유찰횟수·매각기일 조건 → 물건 목록 JSON
- ✅ 법원사무소 코드(60+개) + 입찰구분 코드(기일입찰=`000331`, 기간입찰=`000332`) + Workflow C용 대표 용도/지역 코드 변환
- ✅ 2-tier transport — direct HTTP 1차, Playwright fallback 옵션
- ✅ 안티봇 가드 — 호출 간 ≥2초 jitter, 세션당 호출 budget, `data.ipcheck === false` 즉시 `BLOCKED` throw
## 무엇을 할 수 없나 (별도 follow-up 이슈)
- ❌ Workflow C 자유 조건검색 (지역·용도·가격대·면적·유찰횟수)
- ❌ Workflow D 일별/월별 캘린더
- ❌ 매각물건 사진(전경/개황/내부) URL 노출
- ❌ 매각물건명세서·현황조사서·감정평가서 PDF 다운로드
@ -36,7 +36,11 @@
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 codes usages --pretty
court-auction-notice-search codes regions --pretty
court-auction-notice-search notices --date 2026-04 --court-code B000210 --bid-type date --pretty
court-auction-notice-search search --sido 서울특별시 --sigungu 11680 --usage-large 건물 --usage-medium 21200 \
--price-min 100000000 --price-max 500000000 --sale-from 2026-05-01 --sale-to 2026-05-20 --pretty
court-auction-notice-search case --court-code B000210 --case-number "2024타경100001" --pretty
```
@ -46,7 +50,8 @@ court-auction-notice-search case --court-code B000210 --case-number "2024타경1
const {
searchSaleNotices,
getSaleNoticeDetail,
getCaseByCaseNumber
getCaseByCaseNumber,
searchProperties
} = require("court-auction-notice-search");
const notices = await searchSaleNotices({
@ -67,6 +72,16 @@ const caseInfo = await getCaseByCaseNumber({
courtCode: "B000210",
caseNumber: "2024타경100001"
});
const properties = await searchProperties({
region: { sido: "서울특별시", sigungu: "11680", dong: "11680101" },
usage: { large: "건물" },
priceRange: { min: 100000000, max: 500000000 },
saleDate: { from: "2026-05-01", to: "2026-05-20" },
flbdCount: { min: 1 },
page: 1,
pageSize: 20
});
```
## 사이트 내부 endpoint (직접 캡처한 것)
@ -76,9 +91,10 @@ const caseInfo = await getCaseByCaseNumber({
| 매각공고 목록 | `POST /pgj/pgj143/selectRletDspslPbanc.on` | `{"dma_srchDspslPbanc":{"srchYmd","cortOfcCd","bidDvsCd","srchBtnYn":"Y"}}` (`srchYmd`는 사이트 검색 버튼과 동일하게 `YYYYMM`) |
| 매각공고 상세 | `POST /pgj/pgj143/selectRletDspslPbancDtl.on` | `{"dma_srchGnrlPbanc":{"cortOfcCd","dspslDxdyYmd","jdbnCd",...}}` |
| 사건 단건 | `POST /pgj/pgj15A/selectAuctnCsSrchRslt.on` | `{"dma_srchCsDtlInf":{"cortOfcCd","csNo"}}` |
| 물건 자유 조건검색 | `POST /pgj/pgjsearch/searchControllerMain.on` | canonical body captured via Playwright (`scripts/capture-pgj151-submit.cjs`); fixture at `packages/court-auction-notice-search/test/fixtures/canonical-search-body.json`. `pageNo/pageSize/statNum` 은 number, `pageSize` 는 upstream 드롭다운 값 `10`/`20`/`50`/`100`만 허용, `notifyLoc` 기본 `"off"`. |
| 법원사무소 코드 | `POST /pgj/pgjComm/selectCortOfcCdLst.on` | `{}` |
세션 cookie(`JSESSIONID`, `WMONID`)는 `GET /pgj/index.on?w2xPath=/pgj/ui/pgj100/PGJ143M01.xml&pgjId=143M01` 으로 사전에 한 번 받아둡니다.
세션 cookie(`JSESSIONID`, `WMONID`)는 endpoint별 진입 화면을 먼저 열어 받아둡니다. 매각공고/상세는 `GET /pgj/index.on?w2xPath=/pgj/ui/pgj100/PGJ143M01.xml&pgjId=143M01`, 물건 자유 조건검색(Workflow C)은 `GET /pgj/index.on?w2xPath=/pgj/ui/pgj100/PGJ151F00.xml&pgjId=151F00` 으로 warmup 합니다.
## 설치
@ -92,5 +108,5 @@ npm install playwright-core
## 관련 이슈
- 이 패키지는 [Issue #167](https://github.com/NomaDamas/k-skill/issues/167) 에서 출발했고, A/B 워크플로 + 코드테이블 MVP만 포함합니다.
- 자유 조건검색·캘린더·물건 사진·PDF·동산 경매는 별도 follow-up 이슈로 분리되어 추적됩니다.
- 이 패키지는 [Issue #167](https://github.com/NomaDamas/k-skill/issues/167) 에서 출발했고, #184에서 Workflow C 자유 조건검색을 추가했습니다.
- 캘린더·물건 사진·PDF·동산 경매는 별도 follow-up 이슈로 분리되어 추적됩니다.

View file

@ -12,7 +12,7 @@
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py coupang-product-search/scripts/coupang_partners_mcp.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"typecheck": "tsc --noEmit",
"test": "node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run && npm pack --workspace k-skill-rhwp --dry-run",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace court-auction-notice-search --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run && npm pack --workspace k-skill-rhwp --dry-run",
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
"version-packages": "changeset version",
"release:npm": "changeset publish"

View file

@ -6,10 +6,10 @@
- ✅ Workflow A — 매각공고 목록 + 매각공고 상세(사건/물건 펼치기)
- ✅ Workflow B — 사건번호로 직접 조회 (사건정보·물건내역·매각기일내역·배당요구종기)
- ✅ 코드테이블 — 법원사무소(60+개) + 입찰구분(기일/기간) 코드 매핑
- ✅ Workflow C — 자유 조건검색(지역·용도·가격대·면적·유찰횟수·매각기일)
- ✅ 코드테이블 — 법원사무소(60+개) + 입찰구분(기일/기간) + Workflow C용 용도/지역 대표 코드 매핑
- ✅ 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 이슈
- ❌ 동산(자동차·중기) 경매 — 본 패키지 범위 밖
@ -38,8 +38,11 @@ const {
searchSaleNotices,
getSaleNoticeDetail,
getCaseByCaseNumber,
searchProperties,
getCourtCodes,
getBidTypes
getBidTypes,
getUsageCodes,
getRegionCodes
} = require("court-auction-notice-search");
const courts = await getCourtCodes();
@ -68,6 +71,20 @@ const caseInfo = await getCaseByCaseNumber({
if (caseInfo.found) {
console.log(caseInfo.caseInfo.caseName, caseInfo.schedule.length);
}
const properties = await searchProperties({
region: { sido: "서울특별시", sigungu: "11680" },
usage: { large: "건물", medium: "21200", small: "21201" },
priceRange: { min: 100000000, max: 500000000 },
appraisedPriceRange: { min: 150000000, max: 800000000 },
saleDate: { from: "2026-05-01", to: "2026-05-20" },
flbdCount: { min: 1, max: 3 },
area: { min: 30, max: 85.5 },
bidType: "date",
page: 1,
pageSize: 20
});
console.log(properties.items[0]);
```
## CLI
@ -76,7 +93,11 @@ if (caseInfo.found) {
court-auction-notice-search -h
court-auction-notice-search codes courts --pretty
court-auction-notice-search codes bid-types --pretty
court-auction-notice-search codes usages --pretty
court-auction-notice-search codes regions --pretty
court-auction-notice-search notices --date 2026-04 --court-code B000210 --bid-type date --pretty
court-auction-notice-search search --sido 서울특별시 --sigungu 11680 --usage-large 건물 --usage-medium 21200 \
--price-min 100000000 --price-max 500000000 --sale-from 2026-05-01 --sale-to 2026-05-20 --pretty
court-auction-notice-search case --court-code B000210 --case-number "2024타경100001" --pretty
```
@ -94,8 +115,21 @@ court-auction-notice-search case --court-code B000210 --case-number "2024타경1
- `getCaseByCaseNumber({ courtCode, caseNumber, includeRaw?, client? })`
- `caseNumber`: `"2024타경100001"` 권장. `"2024-100001"`, `"2024_100001"` 등은 자동 정규화.
- returns `{ found, status, message, caseInfo, items[], schedule[], claimDeadline, relatedCases[], appeals[], stakeholders[], raw? }`
- `searchProperties({ region?, usage?, priceRange?, appraisedPriceRange?, saleDate?, flbdCount?, area?, bidType?, courtCode?, page?, pageSize?, includeRaw?, client?, fallback?, fallbackOnBlocked? })`
- `region`: `{ sido, sigungu, dong }` — sido는 코드(`"11"`) 또는 한국어명(`"서울특별시"`). 시군구/읍면동은 raw 코드(예: `"11680"`/`"11680101"`)로 전달. 입력하면 `cortStDvs:"2"` 지번주소 검색.
- `usage`: `{ large, medium, small }` — 5자리 upstream 코드(`"20000"`=건물) 또는 대분류 한국어명(`"건물"`/`"토지"`/`"차량및운송장비"`/`"기타"`).
- `priceRange`: 최저매각가격 원 단위 `{ min, max }` (실수 허용)
- `appraisedPriceRange`: 감정평가액 원 단위 `{ min, max }` (실수 허용)
- `saleDate`: `{ from, to }` (`YYYY-MM-DD`/`YYYYMMDD`)
- `flbdCount`: 유찰횟수 `{ min, max }` **정수만**
- `area`: 면적(㎡) `{ min, max }` (실수 허용)
- `pageSize`: upstream PGJ151 드롭다운에서 확인된 `10`, `20`, `50`, `100` 중 하나(기본 10). `1` 등 임의 값은 live endpoint 가 HTTP 400을 반환하므로 로컬에서 거부한다.
- `fallback`: `false` 면 Playwright auto-fallback 비활성. 기본 true (Workflow C raw-HTTP WAF의 HTTP 400 시 Playwright 로 재시도, `playwright-core`/`rebrowser-playwright` 미설치 시 자동 무시). `BLOCKED`(`ipcheck=false`)는 기본적으로 즉시 중단하며, 명시적으로 `fallbackOnBlocked:true` 를 준 경우에만 재시도한다.
- returns `{ requestedFilters, page, count, items[] }``items[i]``{ caseNumber, displayCaseNumber, itemNumber, address, appraisedPrice, minimumSalePrice, flbdCount, statusCode, progressStatusCode, courtCode, courtName, judgeDeptCode, judgeDeptName, documentId, saleDate, salePlace, bidTypeCode, usageCodes, regionCodes, coordinates, coordinatesWgs84, buildingList, areaList, landCategoryList, propertyDescription, areaRange, remarks, raw }`
- `getCourtCodes({ client? })` — 법원사무소 코드표 동적 로드
- `getBidTypes()` — 입찰구분 정적 코드표 (기일입찰=`000331`, 기간입찰=`000332`)
- `getUsageCodes()` — Workflow C용 정적 코드표. **upstream `selectLclLst.on` 캡처에서 가져온 4개 대분류(`10000=토지`, `20000=건물`, `30000=차량및운송장비`, `40000=기타`)** 와 대표 중/소분류 일부. 알 수 없는 값은 fail-open.
- `getRegionCodes()` — Workflow C용 정적 시도 코드표(19행). upstream `selectAdongSdLst.on` 캡처. 시군구/읍면동은 cascade XHR이 안정적으로 노출되지 않아 정적 표에 미포함; raw 코드(`"11680"` 등)를 그대로 전달.
- `resolveBidTypeCode(input)`, `describeBidTypeCode(code)` — 코드 변환 헬퍼
- `CourtAuctionHttpClient` — direct HTTP 클라이언트. fetchImpl, timeoutMs, minDelayMs, jitterMs, maxCallsPerSession 모두 override 가능.
- `CourtAuctionPlaywrightClient``playwright-core` / `rebrowser-playwright` 가 있을 때만 사용. `postJson(endpointKey, body)` 시그니처는 동일.
@ -136,6 +170,7 @@ discovery 시 직접 캡처한 사이트 내부 endpoint:
| 매각공고 목록 | `POST /pgj/pgj143/selectRletDspslPbanc.on` | `dma_srchDspslPbanc.{srchYmd, cortOfcCd, bidDvsCd, srchBtnYn:"Y"}``srchYmd`는 사이트 검색 버튼과 동일하게 `YYYYMM` 월 단위 |
| 매각공고 상세 | `POST /pgj/pgj143/selectRletDspslPbancDtl.on` | `dma_srchGnrlPbanc.{cortOfcCd, dspslDxdyYmd, jdbnCd, ...}` |
| 사건 단건 | `POST /pgj/pgj15A/selectAuctnCsSrchRslt.on` | `dma_srchCsDtlInf.{cortOfcCd, csNo}` |
| 물건 자유 조건검색 | `POST /pgj/pgjsearch/searchControllerMain.on` | canonical body shape: `dma_pageInfo.{pageNo:Number, pageSize:Number, bfPageNo, startRowNo, totalCnt, totalYn:"Y", groupTotalCount}` + `dma_srchGdsDtlSrchInfo.{rletDspslSpcCondCd, bidDvsCd, mvprpRletDvsCd:"00031R", cortAuctnSrchCondCd:"0004601", rprsAdong*Cd, rdnm*, mvprpDspslPlcAdong*Cd, rdDspslPlcAdong*Cd, cortOfcCd, jdbnCd, execrOfcDvsCd, lcl/mcl/sclDspslGdsLstUsgCd, cortAuctnMbrsId, aeeEvlAmt*, lwsDspslPrcRate*, flbdNcnt*, objctArDts*, mvprpArtclKndCd, mvprpArtclNm, mvprpAtchmPlcTypCd, notifyLoc:"off", lafjOrderBy, pgmId:"PGJ151F01", csNo, cortStDvs:"1"or"2", statNum:1, bidBgngYmd, bidEndYmd, dspslDxdyYmd, fst/scnd/thrd/foth DspslHm, dspslPlcNm, lwsDspslPrc*, grbxTypCd, gdsVendNm, fuelKndCd, carMd*, sideDvsCd}`. Captured 2026-05-08 from a real browser submission via `scripts/capture-pgj151-submit.cjs`; canonical fixture at `test/fixtures/canonical-search-body.json`. |
| 법원사무소 | `POST /pgj/pgjComm/selectCortOfcCdLst.on` | `{}` |
## Verification

View file

@ -0,0 +1,98 @@
"use strict";
/**
* Discovery script capture the canonical PGJ151 search request body and a
* representative successful response from a real browser submission.
*
* Why: courtauction.go.kr uses an aggressive WAF that returns HTTP 400 to
* direct fetch calls when the session has not been warmed up by a real
* browser. The only reliable way to capture a ground-truth request body
* is to drive the live page with Playwright and click the actual 검색 button.
*
* Output:
* - The exact JSON body the WebSquare submission posts to
* /pgj/pgjsearch/searchControllerMain.on
* - The HTTP 200 response body (truncated preview).
*
* Usage:
* node packages/court-auction-notice-search/scripts/capture-pgj151-submit.cjs
*
* Slow-by-design: one search per run, default headless. Do NOT loop this script.
*/
const { chromium } = require("playwright-core");
const URL_BASE = "https://www.courtauction.go.kr";
const PGJ151 = `${URL_BASE}/pgj/index.on?w2xPath=/pgj/ui/pgj100/PGJ151F00.xml&pgjId=151F00`;
(async () => {
let browser;
try {
browser = await chromium.launch({ headless: true, channel: "chrome" });
} catch {
browser = await chromium.launch({ headless: true });
}
const context = await browser.newContext({
userAgent:
"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",
locale: "ko-KR",
viewport: { width: 1280, height: 900 }
});
const page = await context.newPage();
const captures = [];
page.on("request", (req) => {
const url = req.url();
if (url.includes("/pgj/") && req.method() === "POST" && url.endsWith(".on")) {
let postData = null;
try { postData = req.postData(); } catch {}
captures.push({ kind: "req", url, postData });
}
});
page.on("response", async (resp) => {
const url = resp.url();
if (url.includes("/pgj/") && url.endsWith(".on") && resp.request().method() === "POST") {
try {
const body = await resp.text();
captures.push({ kind: "resp", url, status: resp.status(), bodyPreview: body.slice(0, 6000) });
} catch {}
}
});
console.log("[1] Goto PGJ151F00");
await page.goto(PGJ151, { waitUntil: "domcontentloaded", timeout: 30000 });
await page.waitForTimeout(7000);
const initialCount = captures.length;
console.log("[2] Click input[type=button][value=검색] (id=mf_wfm_mainFrame_btn_gdsDtlSrch)");
const btn = page.locator('input[type="button"][value="검색"]').first();
await btn.click({ timeout: 5000 }).catch((e) => console.log(" click err:", e.message));
await page.waitForTimeout(8000);
console.log("\n=== POST-CLICK CAPTURES (filter to searchControllerMain) ===");
const newCaptures = captures.slice(initialCount);
const interesting = newCaptures.filter((c) => c.url.includes("searchControllerMain"));
for (const c of interesting) {
if (c.kind === "req") {
console.log("\n[REQUEST BODY]");
try {
console.log(JSON.stringify(JSON.parse(c.postData), null, 2));
} catch {
console.log(c.postData);
}
} else {
console.log(`\n[RESPONSE ${c.status}]`);
console.log(c.bodyPreview.slice(0, 2500));
}
}
if (interesting.length === 0) {
console.log("\nNo searchControllerMain capture observed. Site may have rate-limited this IP.");
}
await browser.close();
})().catch((e) => {
console.error("FATAL:", e);
process.exit(1);
});

View file

@ -4,8 +4,11 @@ const {
searchSaleNotices,
getSaleNoticeDetail,
getCaseByCaseNumber,
searchProperties,
getCourtCodes,
getBidTypes,
getUsageCodes,
getRegionCodes,
resolveBidTypeCode,
CourtAuctionHttpClient
} = require("./index");
@ -17,7 +20,16 @@ USAGE
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 search [--region <시도[:시군구raw[:읍면동raw]]>] [--usage <[:[:]]>]
[--sido <code|name>] [--sigungu <raw-code>] [--dong <raw-code>]
[--usage-large <code|name>] [--usage-medium <code|name>] [--usage-small <code|name>]
[--price-min <won>] [--price-max <won>] [--appraised-min <won>] [--appraised-max <won>]
[--sale-from <YYYY-MM-DD>] [--sale-to <YYYY-MM-DD>] [--flbd-min <N>] [--flbd-max <N>]
[--area-min <m2>] [--area-max <m2>] [--court-code <B000210>] [--bid-type date|period]
[--page <N>] [--page-size 10|20|50|100]
court-auction-notice-search codes courts
court-auction-notice-search codes usages
court-auction-notice-search codes regions
court-auction-notice-search codes bid-types
GLOBAL FLAGS
@ -154,12 +166,67 @@ async function runCase(flags) {
emit(result, flags);
}
function splitColonTriple(value) {
if (value === undefined || value === null) return null;
if (typeof value !== "string") return null;
if (!value.includes(":")) return null;
const parts = value.split(":");
return [parts[0] || "", parts[1] || "", parts[2] || ""];
}
function buildRegionFromFlags(flags) {
const colon = splitColonTriple(flags.region);
return {
sido: flags.sido || (colon ? colon[0] : flags.region),
sigungu: flags.sigungu || (colon ? colon[1] : ""),
dong: flags.dong || (colon ? colon[2] : "")
};
}
function buildUsageFromFlags(flags) {
const colon = splitColonTriple(flags.usage);
return {
large: flags["usage-large"] || (colon ? colon[0] : flags.usage),
medium: flags["usage-medium"] || (colon ? colon[1] : ""),
small: flags["usage-small"] || (colon ? colon[2] : "")
};
}
async function runSearch(flags) {
const includeRaw = shouldIncludeRaw(flags);
const result = await searchProperties({
region: buildRegionFromFlags(flags),
usage: buildUsageFromFlags(flags),
priceRange: { min: flags["price-min"], max: flags["price-max"] },
appraisedPriceRange: { min: flags["appraised-min"], max: flags["appraised-max"] },
saleDate: { from: flags["sale-from"], to: flags["sale-to"] },
flbdCount: { min: flags["flbd-min"], max: flags["flbd-max"] },
area: { min: flags["area-min"], max: flags["area-max"] },
bidType: flags["bid-type"],
courtCode: flags["court-code"] || flags.court || "",
page: flags.page,
pageSize: flags["page-size"] || flags.pageSize,
client: buildClient(flags),
fallback: flags["fallback"] !== "false",
includeRaw: includeRaw === undefined ? true : includeRaw
});
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 === "usages" || sub === "usage") {
emit(getUsageCodes(), flags);
return;
}
if (sub === "regions" || sub === "region") {
emit(getRegionCodes(), flags);
return;
}
if (!sub || sub === "courts") {
const result = await getCourtCodes({ client: buildClient(flags) });
emit(result, flags);
@ -191,6 +258,10 @@ async function main(argv) {
case "case":
await runCase(args.flags);
return 0;
case "search":
case "properties":
await runSearch(args.flags);
return 0;
case "codes":
await runCodes(args._, args.flags);
return 0;

View file

@ -6,6 +6,12 @@ const fs = require("node:fs");
const bidTypesData = JSON.parse(
fs.readFileSync(path.join(__dirname, "bid-types.json"), "utf8")
);
const usageCodesData = JSON.parse(
fs.readFileSync(path.join(__dirname, "usage-codes.json"), "utf8")
);
const regionCodesData = JSON.parse(
fs.readFileSync(path.join(__dirname, "region-codes.json"), "utf8")
);
const BID_TYPES = Object.freeze(
bidTypesData.bidTypes.map((entry) => Object.freeze({ ...entry }))
@ -14,6 +20,15 @@ const BID_TYPES = Object.freeze(
const BID_TYPE_BY_ALIAS = new Map();
const BID_TYPE_BY_CODE = new Map();
const BID_TYPE_BY_NAME = new Map();
const USAGE_BY_NAME = new Map();
const USAGE_BY_CODE = new Map();
const USAGE_CODES = Object.freeze(
usageCodesData.items.map((entry) => Object.freeze({ ...entry }))
);
const REGION_CODES = Object.freeze(
regionCodesData.items.map((entry) => Object.freeze({ ...entry }))
);
for (const entry of BID_TYPES) {
BID_TYPE_BY_ALIAS.set(entry.alias, entry);
@ -21,6 +36,94 @@ for (const entry of BID_TYPES) {
BID_TYPE_BY_NAME.set(entry.name, entry);
}
for (const entry of USAGE_CODES) {
USAGE_BY_CODE.set(entry.code, entry);
USAGE_BY_NAME.set(entry.name, entry);
}
/**
* Resolve a usage classification name/code to its upstream `lcl/mcl/sclDspslGdsLstUsgCd`.
*
* Strict-level matching: when `level` is supplied (`"large"`, `"medium"`, `"small"`),
* the function only returns a code that is registered at that exact level. If the
* input name exists at a different level we fail open (return the raw input) instead
* of silently mapping to a wrong-level code same-name codes (e.g. `"아파트"`) exist
* at multiple levels and a wrong-level mapping would silently corrupt the request.
*
* Codes (5-digit, e.g. `"20000"`) are accepted as-is regardless of `level`.
*
* Returns `""` for empty input.
*/
function resolveUsageCode(input, level) {
if (input === undefined || input === null || input === "") return "";
const value = String(input).trim();
if (value === "") return "";
// Direct code match — accepted at any level (the upstream determines validity).
const codeMatch = USAGE_BY_CODE.get(value);
if (codeMatch) return codeMatch.code;
if (level) {
// Strict-level match. Do NOT fall back to USAGE_BY_NAME because that would
// ignore the level constraint and return a wrong-level code for ambiguous names.
const nameMatch = USAGE_CODES.find(
(entry) => entry.name === value && entry.level === level
);
if (nameMatch) return nameMatch.code;
// Fail open: pass the raw input through so the user sees an upstream error
// instead of a silently wrong code.
return value;
}
// No level specified — match the first registered name (any level).
const nameMatch = USAGE_BY_NAME.get(value);
if (nameMatch) return nameMatch.code;
return value;
}
/**
* Resolve a region (sido/sigungu/dong) input to upstream `rprsAdong*Cd` codes.
*
* - Each component is independently resolved against the static sido table by
* exact code or Korean name match. Sigungu/dong are NOT in the static table
* (the upstream cascading XHRs are not consistently exposed) so they pass
* through unchanged when supplied as raw codes.
* - When ALL three inputs are empty, returns `{ "", "", "" }` this is the
* correct "no region filter" state (cortStDvs:"1" branch in the search body).
* - When any input is non-empty, it is returned (resolved-or-passthrough);
* empty inputs stay empty. There is no first-row fallback.
*/
function resolveRegionCodes(input = {}) {
if (!input || typeof input !== "object") {
return { sido: "", sigungu: "", dong: "" };
}
const rawSido = input.sido === undefined || input.sido === null ? "" : String(input.sido).trim();
const rawSigungu = input.sigungu === undefined || input.sigungu === null ? "" : String(input.sigungu).trim();
const rawDong = input.dong === undefined || input.dong === null ? "" : String(input.dong).trim();
let sido = rawSido;
if (sido) {
const sidoMatch = REGION_CODES.find(
(entry) => entry.sidoCode === sido || entry.sidoName === sido
);
if (sidoMatch) sido = sidoMatch.sidoCode;
}
// Sigungu/dong: pass through raw codes / names. The upstream expects 5-digit
// sigungu codes (e.g. "11680" 강남구) and 8-digit dong codes (e.g. "11680101"
// 역삼동) observed in dlt_srchResult.srchHjguSiguCd/srchHjguDongCd. Names
// without code mapping are passed through unchanged (fail-open).
return { sido, sigungu: rawSigungu, dong: rawDong };
}
function listUsageCodes() {
return USAGE_CODES.map((entry) => ({ ...entry }));
}
function listRegionCodes() {
return REGION_CODES.map((entry) => ({ ...entry }));
}
/**
* Resolve a bid type input (alias / code / korean name) to its raw `bidDvsCd`.
* Returns "" if the input is empty/undefined (meaning "all types").
@ -70,7 +173,13 @@ function listBidTypes() {
module.exports = {
BID_TYPES,
USAGE_CODES,
REGION_CODES,
resolveBidTypeCode,
describeBidTypeCode,
listBidTypes
listBidTypes,
resolveUsageCode,
resolveRegionCodes,
listUsageCodes,
listRegionCodes
};

View file

@ -0,0 +1,24 @@
{
"source": "courtauction.go.kr PGJ151F00 initial XHR /pgj/pgj002/selectAdongSdLst.on captured 2026-05-08. Discovery raw: test/fixtures/sido-codes-raw.json. Sigungu/dong are NOT included here because the upstream cascading XHRs are not consistently exposed; resolveRegionCodes() falls open on raw codes (pass `{ sido: \"11\", sigungu: \"11680\", dong: \"11680101\" }` directly when known). The dong/sigungu code shape (5-digit sigungu, 8-digit dong) is observed in dlt_srchResult.srchHjguSiguCd/srchHjguDongCd.",
"items": [
{ "sidoCode": "11", "sidoName": "서울특별시" },
{ "sidoCode": "26", "sidoName": "부산광역시" },
{ "sidoCode": "27", "sidoName": "대구광역시" },
{ "sidoCode": "28", "sidoName": "인천광역시" },
{ "sidoCode": "29", "sidoName": "광주광역시" },
{ "sidoCode": "30", "sidoName": "대전광역시" },
{ "sidoCode": "31", "sidoName": "울산광역시" },
{ "sidoCode": "36", "sidoName": "세종특별자치시" },
{ "sidoCode": "41", "sidoName": "경기도" },
{ "sidoCode": "42", "sidoName": "강원도" },
{ "sidoCode": "43", "sidoName": "충청북도" },
{ "sidoCode": "44", "sidoName": "충청남도" },
{ "sidoCode": "45", "sidoName": "전라북도" },
{ "sidoCode": "46", "sidoName": "전라남도" },
{ "sidoCode": "47", "sidoName": "경상북도" },
{ "sidoCode": "48", "sidoName": "경상남도" },
{ "sidoCode": "50", "sidoName": "제주특별자치도" },
{ "sidoCode": "51", "sidoName": "강원특별자치도" },
{ "sidoCode": "52", "sidoName": "전북특별자치도" }
]
}

View file

@ -0,0 +1,13 @@
{
"source": "courtauction.go.kr PGJ151F00 initial XHR /pgj/pgj002/selectLclLst.on captured 2026-05-08; mid/small representative codes observed in dlt_srchResult.lclsUtilCd/mclsUtilCd/sclsUtilCd. Discovery raw: test/fixtures/lcl-codes-raw.json. Workflow C resolver fails open on unknown values (raw codes pass through unchanged). Mid/small classifications are NOT exhaustive — the upstream PGJ151 cascading dropdown does not return them via a public XHR; pass raw codes when known.",
"items": [
{ "level": "large", "code": "10000", "name": "토지" },
{ "level": "large", "code": "20000", "name": "건물" },
{ "level": "large", "code": "30000", "name": "차량및운송장비" },
{ "level": "large", "code": "40000", "name": "기타" },
{ "level": "medium", "parentCode": "20000", "code": "21100", "name": "단독주택" },
{ "level": "medium", "parentCode": "20000", "code": "21200", "name": "공동주택" },
{ "level": "small", "parentCode": "21100", "code": "21101", "name": "단독주택(소분류)" },
{ "level": "small", "parentCode": "21200", "code": "21201", "name": "아파트" }
]
}

View file

@ -15,13 +15,18 @@ const {
resolveBidTypeCode,
describeBidTypeCode,
listBidTypes,
BID_TYPES
BID_TYPES,
resolveUsageCode,
resolveRegionCodes,
listUsageCodes,
listRegionCodes
} = require("./codetables");
const {
normalizeNoticeListResponse,
normalizeNoticeDetailResponse,
normalizeCourtCodesResponse,
normalizeCaseDetailResponse
normalizeCaseDetailResponse,
normalizePropertySearchResponse
} = require("./normalize");
function toYmd(input, label) {
@ -36,6 +41,49 @@ function toYmd(input, label) {
return compact;
}
function optionalYmd(input, label) {
if (input === null || input === undefined || input === "") return "";
return toYmd(input, label);
}
function toPositiveInt(input, fallback, label, opts = {}) {
if (input === null || input === undefined || input === "") return fallback;
const value = Number(input);
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${label} must be a positive integer, got "${input}"`);
}
if (Array.isArray(opts.allowed) && !opts.allowed.includes(value)) {
throw new Error(`${label} must be one of ${opts.allowed.join(", ")}, got ${value}`);
}
if (typeof opts.max === "number" && value > opts.max) {
throw new Error(
`${label} must be <= ${opts.max} (court auction site upper bound), got ${value}`
);
}
return value;
}
const PAGE_SIZE_VALUES = [10, 20, 50, 100];
function rangeValue(range, key, opts = {}) {
if (!range || typeof range !== "object") return "";
const value = range[key];
if (value === null || value === undefined || value === "") return "";
const text = String(value).trim().replace(/,/g, "");
if (opts.integerOnly) {
if (!/^\d+$/.test(text)) {
throw new Error(
`${opts.label || key} range value must be a non-negative integer, got "${value}"`
);
}
} else if (!/^\d+(?:\.\d+)?$/.test(text)) {
throw new Error(
`${opts.label || key} range value must be numeric, got "${value}"`
);
}
return text;
}
function toNoticeSearchDate(input, label) {
if (input === null || input === undefined || input === "") {
throw new Error(`${label} is required (YYYY-MM, YYYYMM, YYYY-MM-DD, or YYYYMMDD)`);
@ -250,6 +298,137 @@ async function getCaseByCaseNumber(params = {}) {
});
}
function buildPropertySearchBody(params = {}) {
const pageNo = toPositiveInt(params.page, 1, "page");
const pageSize = toPositiveInt(params.pageSize, 10, "pageSize", { allowed: PAGE_SIZE_VALUES });
const courtCodeRaw =
params.courtCode === undefined || params.courtCode === null ? "" : String(params.courtCode).trim();
const courtCode = courtCodeRaw === "" ? "" : ensureCourtCode(courtCodeRaw);
const region = resolveRegionCodes(params.region || {});
const usage = params.usage && typeof params.usage === "object" ? params.usage : {};
const saleDate = params.saleDate && typeof params.saleDate === "object" ? params.saleDate : {};
const hasRegion = Boolean(region.sido || region.sigungu || region.dong);
const body = {
dma_pageInfo: {
pageNo,
pageSize,
bfPageNo: "",
startRowNo: "",
totalCnt: "",
totalYn: params.totalYn === "N" ? "N" : "Y",
groupTotalCount: ""
},
dma_srchGdsDtlSrchInfo: {
rletDspslSpcCondCd: "",
bidDvsCd: resolveBidTypeCode(params.bidType),
mvprpRletDvsCd: "00031R",
cortAuctnSrchCondCd: "0004601",
rprsAdongSdCd: region.sido,
rprsAdongSggCd: region.sigungu,
rprsAdongEmdCd: region.dong,
rdnmSdCd: "",
rdnmSggCd: "",
rdnmNo: "",
mvprpDspslPlcAdongSdCd: "",
mvprpDspslPlcAdongSggCd: "",
mvprpDspslPlcAdongEmdCd: "",
rdDspslPlcAdongSdCd: "",
rdDspslPlcAdongSggCd: "",
rdDspslPlcAdongEmdCd: "",
cortOfcCd: courtCode,
jdbnCd: params.judgeDeptCode ? String(params.judgeDeptCode).trim() : "",
execrOfcDvsCd: "",
lclDspslGdsLstUsgCd: resolveUsageCode(usage.large, "large"),
mclDspslGdsLstUsgCd: resolveUsageCode(usage.medium, "medium"),
sclDspslGdsLstUsgCd: resolveUsageCode(usage.small, "small"),
cortAuctnMbrsId: "",
aeeEvlAmtMin: rangeValue(params.appraisedPriceRange, "min", { label: "appraisedPriceRange.min" }),
aeeEvlAmtMax: rangeValue(params.appraisedPriceRange, "max", { label: "appraisedPriceRange.max" }),
lwsDspslPrcRateMin: rangeValue(params.minimumSalePriceRateRange, "min", { label: "minimumSalePriceRateRange.min" }),
lwsDspslPrcRateMax: rangeValue(params.minimumSalePriceRateRange, "max", { label: "minimumSalePriceRateRange.max" }),
flbdNcntMin: rangeValue(params.flbdCount, "min", { integerOnly: true, label: "flbdCount.min" }),
flbdNcntMax: rangeValue(params.flbdCount, "max", { integerOnly: true, label: "flbdCount.max" }),
objctArDtsMin: rangeValue(params.area, "min", { label: "area.min" }),
objctArDtsMax: rangeValue(params.area, "max", { label: "area.max" }),
mvprpArtclKndCd: "",
mvprpArtclNm: "",
mvprpAtchmPlcTypCd: "",
notifyLoc: params.notifyLocation ? "Y" : "off",
lafjOrderBy: params.orderBy ? String(params.orderBy) : "",
pgmId: "PGJ151F01",
csNo: "",
cortStDvs: hasRegion ? "2" : "1",
statNum: 1,
bidBgngYmd: optionalYmd(saleDate.from, "saleDate.from"),
bidEndYmd: optionalYmd(saleDate.to, "saleDate.to"),
dspslDxdyYmd: "",
fstDspslHm: "",
scndDspslHm: "",
thrdDspslHm: "",
fothDspslHm: "",
dspslPlcNm: "",
lwsDspslPrcMin: rangeValue(params.priceRange, "min", { label: "priceRange.min" }),
lwsDspslPrcMax: rangeValue(params.priceRange, "max", { label: "priceRange.max" }),
grbxTypCd: "",
gdsVendNm: "",
fuelKndCd: "",
carMdyrMax: "",
carMdyrMin: "",
carMdlNm: "",
sideDvsCd: ""
}
};
return body;
}
async function searchProperties(params = {}) {
const body = buildPropertySearchBody(params);
const includeRaw = params.includeRaw !== false;
const primary = ensureClient(params.client, params);
let raw;
try {
raw = await primary.postJson("propertySearch", body);
} catch (err) {
const isConfirmedBlocked = err && err.code === "BLOCKED";
const isWafHttp400 = err && err.code === "UPSTREAM_ERROR" && err.statusCode === 400;
const isFallbackEligibleError = isWafHttp400 || (isConfirmedBlocked && params.fallbackOnBlocked === true);
const canFallbackFromClient =
!params.client || params.fallbackClient || primary instanceof CourtAuctionHttpClient;
const fallbackEnabled = params.fallback !== false && canFallbackFromClient;
if (!isFallbackEligibleError || !fallbackEnabled) {
throw err;
}
const fallback =
params.fallbackClient ||
(isFallbackAvailable()
? new CourtAuctionPlaywrightClient({
...pickClientOptions(params),
headless: params.headless !== false,
chromiumLoader: params.chromiumLoader
})
: null);
if (!fallback) {
throw err;
}
try {
raw = await fallback.postJson("propertySearch", body);
} finally {
if (!params.fallbackClient && typeof fallback.close === "function") {
await fallback.close().catch(() => {});
}
}
}
return normalizePropertySearchResponse(raw, {
requestedFilters: body.dma_srchGdsDtlSrchInfo,
includeRaw
});
}
async function getCourtCodes(options = {}) {
const client = ensureClient(options.client, options);
const raw = await client.postJson("courts", {});
@ -260,6 +439,16 @@ function getBidTypes() {
return listBidTypes();
}
function getUsageCodes() {
const items = listUsageCodes();
return { count: items.length, items };
}
function getRegionCodes() {
const items = listRegionCodes();
return { count: items.length, items };
}
module.exports = {
ENDPOINT_PATHS,
WARMUP_PATH,
@ -278,6 +467,10 @@ module.exports = {
getSaleNoticeDetail,
buildNoticeDetailBody,
getCaseByCaseNumber,
searchProperties,
buildPropertySearchBody,
getCourtCodes,
getBidTypes
getBidTypes,
getUsageCodes,
getRegionCodes
};

View file

@ -34,6 +34,16 @@ function parseAmount(value) {
return Number.isFinite(num) ? num : null;
}
function parseNumber(value) {
if (value === null || value === undefined) return null;
const stripped = stripHtml(value);
if (!stripped) return null;
const normalized = stripped.replace(/[, ]/g, "");
if (!/^-?\d+(?:\.\d+)?$/.test(normalized)) return null;
const num = Number(normalized);
return Number.isFinite(num) ? num : null;
}
function formatYmd(value) {
if (value === null || value === undefined) return null;
const trimmed = String(value).trim();
@ -344,6 +354,106 @@ function normalizeCaseDetailResponse(rawPayload, options = {}) {
return result;
}
function normalizePropertySearchResponse(rawPayload, options = {}) {
const data = rawPayload && typeof rawPayload === "object" ? rawPayload.data : null;
const pageInfo = data && typeof data.dma_pageInfo === "object" ? data.dma_pageInfo : {};
const list = data && Array.isArray(data.dlt_srchResult) ? data.dlt_srchResult : [];
const includeRaw = options.includeRaw !== false;
const items = list.map((row) => normalizePropertySearchRow(row, includeRaw));
return {
requestedFilters: options.requestedFilters || null,
page: {
pageNo: parseAmount(pageInfo.pageNo) || 1,
pageSize: parseAmount(pageInfo.pageSize) || items.length,
totalCount: parseAmount(pageInfo.totalCnt) || items.length,
totalYn: nullIfBlank(pageInfo.totalYn),
groupTotalCount: parseAmount(pageInfo.groupTotalCount)
},
count: items.length,
items
};
}
function buildAddress(row) {
const parts = [
stripHtml(row.hjguSido),
stripHtml(row.hjguSigu),
stripHtml(row.hjguDong),
stripHtml(row.hjguRd),
stripHtml(row.daepyoLotno),
stripHtml(row.buldNm)
].filter((part) => part);
if (parts.length === 0) {
return stripHtml(row.realSt) || stripHtml(row.printSt) || null;
}
return parts.join(" ");
}
function normalizePropertySearchRow(rawRow, includeRaw) {
const row = ensureRow(rawRow);
const x = parseNumber(row.xCordi);
const y = parseNumber(row.yCordi);
const wgsX = parseNumber(row.wgs84Xcordi);
const wgsY = parseNumber(row.wgs84Ycordi);
const itemSeq = nullIfBlank(row.mokmulSer) || nullIfBlank(row.maemulSer);
const failedBidCount = parseAmount(row.yuchalCnt) || 0;
const status = nullIfBlank(row.mulStatcd);
const buildings = stripHtml(row.buldList);
const areas = stripHtml(row.areaList);
const lotCategories = stripHtml(row.jimokList);
const out = {
caseNumber: nullIfBlank(row.saNo),
displayCaseNumber: nullIfBlank(row.srnSaNo) || nullIfBlank(row.printCsNo),
itemNumber: itemSeq,
itemSeq,
address: buildAddress(row),
appraisedPrice: parseAmount(row.gamevalAmt),
minimumSalePrice: parseAmount(row.minmaePrice),
flbdCount: failedBidCount,
failedBidCount,
statusCode: status,
status,
progressStatusCode: nullIfBlank(row.jinstatCd),
courtCode: nullIfBlank(row.boCd),
courtName: nullIfBlank(row.jiwonNm),
judgeDeptCode: nullIfBlank(row.jpDeptCd),
judgeDeptName: nullIfBlank(row.jpDeptNm),
documentId: nullIfBlank(row.docid),
saleDate: formatYmd(row.maeGiil),
salePlace: nullIfBlank(row.maePlace),
bidTypeCode: nullIfBlank(row.ipchalGbncd),
usageCodes: {
large: nullIfBlank(row.lclsUtilCd),
medium: nullIfBlank(row.mclsUtilCd),
small: nullIfBlank(row.sclsUtilCd)
},
regionCodes: {
sido: nullIfBlank(row.srchHjguSidoCd) || nullIfBlank(row.daepyoSidoCd),
sigungu: nullIfBlank(row.srchHjguSiguCd) || nullIfBlank(row.daepyoSiguCd),
dong: nullIfBlank(row.srchHjguDongCd) || nullIfBlank(row.daepyoDongCd)
},
coordinates: x === null && y === null ? null : { x, y },
coordinatesWgs84: wgsX === null && wgsY === null ? null : { x: wgsX, y: wgsY },
buildingList: buildings,
buildings,
areaList: areas,
areas,
landCategoryList: lotCategories,
lotCategories,
propertyDescription: stripHtml(row.pjbBuldList),
areaRange: {
min: parseNumber(row.minArea),
max: parseNumber(row.maxArea)
},
remarks: stripHtml(row.mulBigo)
};
if (includeRaw) {
out.raw = { ...row };
}
return out;
}
module.exports = {
normalizeNoticeListResponse,
normalizeNoticeRow,
@ -351,7 +461,10 @@ module.exports = {
normalizeNoticeDetailRow,
normalizeCourtCodesResponse,
normalizeCaseDetailResponse,
normalizePropertySearchResponse,
normalizePropertySearchRow,
parseAmount,
parseNumber,
stripHtml,
formatYmd,
formatHm

View file

@ -12,6 +12,7 @@ const ENDPOINT_PATHS = Object.freeze({
notices: "/pgj/pgj143/selectRletDspslPbanc.on",
noticeDetail: "/pgj/pgj143/selectRletDspslPbancDtl.on",
caseDetail: "/pgj/pgj15A/selectAuctnCsSrchRslt.on",
propertySearch: "/pgj/pgjsearch/searchControllerMain.on",
courts: "/pgj/pgjComm/selectCortOfcCdLst.on"
});
@ -21,9 +22,26 @@ const ENDPOINT_REFERER_HINT = Object.freeze({
"/pgj/index.on?w2xPath=/pgj/ui/pgj100/PGJ143M01.xml&pgjId=143M01",
caseDetail:
"/pgj/index.on?w2xPath=/pgj/ui/pgj100/PGJ159M00.xml&pgjId=159M00",
propertySearch:
"/pgj/index.on?w2xPath=/pgj/ui/pgj100/PGJ151F00.xml&pgjId=151F00",
courts: "/pgj/index.on?w2xPath=/pgj/ui/pgj100/PGJ143M01.xml&pgjId=143M01"
});
const ENDPOINT_WARMUP_PATH = 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",
propertySearch:
"/pgj/index.on?w2xPath=/pgj/ui/pgj100/PGJ151F00.xml&pgjId=151F00",
courts: "/pgj/index.on?w2xPath=/pgj/ui/pgj100/PGJ143M01.xml&pgjId=143M01"
});
const ENDPOINT_SUBMISSION_ID = Object.freeze({
propertySearch: "mf_wfm_mainFrame_sbm_selectGdsDtlSrch"
});
function createBlockedError(message, payload) {
const error = new Error(
message ||
@ -130,7 +148,7 @@ class CourtAuctionHttpClient {
? options.maxCallsPerSession
: 10;
this.cookieJar = new Map();
this.warmedUp = false;
this.warmedUp = null;
this.callsSoFar = 0;
this.lastCallAt = 0;
this.now = typeof options.now === "function" ? options.now : () => Date.now();
@ -139,7 +157,7 @@ class CourtAuctionHttpClient {
resetSession() {
this.cookieJar = new Map();
this.warmedUp = false;
this.warmedUp = null;
this.callsSoFar = 0;
this.lastCallAt = 0;
}
@ -155,6 +173,12 @@ class CourtAuctionHttpClient {
"X-Requested-With": "XMLHttpRequest"
};
const submissionId = ENDPOINT_SUBMISSION_ID[endpointKey];
if (submissionId) {
headers.submissionid = submissionId;
headers["sc-userid"] = "SYSTEM";
}
const cookieHeader = buildCookieHeader(this.cookieJar);
if (cookieHeader) {
headers.Cookie = cookieHeader;
@ -163,21 +187,22 @@ class CourtAuctionHttpClient {
return Object.assign(headers, extra);
}
async warmup() {
if (this.warmedUp) return;
const url = `${this.baseUrl}${WARMUP_PATH}`;
async warmup(endpointKey) {
const warmupPath = ENDPOINT_WARMUP_PATH[endpointKey] || WARMUP_PATH;
if (this.warmedUp === warmupPath) return;
const url = `${this.baseUrl}${warmupPath}`;
const controller = createAbortController(this.timeoutMs);
try {
const response = await this.fetchImpl(url, {
method: "GET",
headers: this.buildHeaders("notices"),
headers: this.buildHeaders(endpointKey || "notices"),
redirect: "manual",
signal: controller.signal
});
ingestSetCookie(this.cookieJar, response.headers);
this.warmedUp = true;
this.warmedUp = warmupPath;
} catch (cause) {
throw createNetworkError(cause, WARMUP_PATH);
throw createNetworkError(cause, warmupPath);
} finally {
controller.cleanup();
}
@ -206,7 +231,7 @@ class CourtAuctionHttpClient {
if (!path) {
throw new Error(`Unknown court auction endpoint: ${endpointKey}`);
}
await this.warmup();
await this.warmup(endpointKey);
await this.ensureBudget();
this.callsSoFar += 1;
@ -288,6 +313,9 @@ function createAbortController(timeoutMs) {
module.exports = {
CourtAuctionHttpClient,
ENDPOINT_PATHS,
ENDPOINT_REFERER_HINT,
ENDPOINT_WARMUP_PATH,
ENDPOINT_SUBMISSION_ID,
WARMUP_PATH,
DEFAULT_BASE_URL,
DEFAULT_USER_AGENT,

View file

@ -2,6 +2,8 @@
const {
ENDPOINT_PATHS,
ENDPOINT_WARMUP_PATH,
WARMUP_PATH: DEFAULT_WARMUP_PATH,
DEFAULT_BASE_URL,
DEFAULT_USER_AGENT,
createBlockedError,
@ -9,8 +11,11 @@ const {
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";
const FALLBACK_MODULE_NAMES = ["playwright-core", "playwright", "rebrowser-playwright"];
const ENDPOINT_SUBMISSION_ID = Object.freeze({
propertySearch: "mf_wfm_mainFrame_sbm_selectGdsDtlSrch"
});
let cachedChromium = null;
@ -67,7 +72,7 @@ class CourtAuctionPlaywrightClient {
this.browser = null;
this.context = null;
this.page = null;
this.warmedUp = false;
this.warmedUp = null;
}
async ensureBrowser() {
@ -83,17 +88,18 @@ class CourtAuctionPlaywrightClient {
this.page = await this.context.newPage();
}
async warmup() {
if (this.warmedUp) return;
async warmup(endpointKey) {
const warmupPath = ENDPOINT_WARMUP_PATH[endpointKey] || DEFAULT_WARMUP_PATH;
if (this.warmedUp === warmupPath) return;
await this.ensureBrowser();
try {
await this.page.goto(`${this.baseUrl}${WARMUP_PATH}`, {
await this.page.goto(`${this.baseUrl}${warmupPath}`, {
waitUntil: "domcontentloaded",
timeout: this.timeoutMs
});
this.warmedUp = true;
this.warmedUp = warmupPath;
} catch (cause) {
throw createNetworkError(cause, WARMUP_PATH);
throw createNetworkError(cause, warmupPath);
}
}
@ -102,29 +108,34 @@ class CourtAuctionPlaywrightClient {
if (!path) {
throw new Error(`Unknown court auction endpoint: ${endpointKey}`);
}
await this.warmup();
await this.warmup(endpointKey);
const url = `${this.baseUrl}${path}`;
const requestPayload = JSON.stringify(body || {});
const submissionId = ENDPOINT_SUBMISSION_ID[endpointKey] || "";
let response;
try {
response = await this.page.evaluate(
async ({ targetUrl, payload }) => {
async ({ targetUrl, payload, submissionid }) => {
const headers = {
"Content-Type": "application/json;charset=UTF-8",
Accept: "application/json"
};
if (submissionid) {
headers.submissionid = submissionid;
headers["sc-userid"] = "SYSTEM";
}
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"
},
headers,
body: payload
});
const text = await res.text();
return { status: res.status, body: text };
},
{ targetUrl: url, payload: requestPayload }
{ targetUrl: url, payload: requestPayload, submissionid: submissionId }
);
} catch (cause) {
throw createNetworkError(cause, path);
@ -181,7 +192,7 @@ class CourtAuctionPlaywrightClient {
this.page = null;
this.context = null;
this.browser = null;
this.warmedUp = false;
this.warmedUp = null;
}
}

View file

@ -28,11 +28,20 @@ test("parseArgs handles --key value, --key=value, and -h", () => {
assert.equal(result.flags.help, true);
});
test("USAGE describes the four subcommands", () => {
test("USAGE describes the supported subcommands", () => {
assert.match(USAGE, /notices --date/);
assert.match(USAGE, /notice-detail/);
assert.match(USAGE, /case --court-code/);
assert.match(USAGE, /search \[--region <시도\[:시군구raw\[:읍면동raw\]\]>/);
assert.match(USAGE, /\[--usage /);
assert.match(USAGE, /\[--sido <code\|name>\]/);
assert.match(USAGE, /\[--sigungu <raw-code>\]/);
assert.match(USAGE, /\[--dong <raw-code>\]/);
assert.match(USAGE, /\[--page-size 10\|20\|50\|100\]/);
assert.match(USAGE, /\[--usage-large /);
assert.match(USAGE, /codes courts/);
assert.match(USAGE, /codes usages/);
assert.match(USAGE, /codes regions/);
assert.match(USAGE, /codes bid-types/);
assert.match(USAGE, /BLOCKED/);
});
@ -66,6 +75,51 @@ test("CLI codes bid-types subcommand returns 기일입찰 + 기간입찰 from th
);
});
test("CLI codes usages and regions expose Workflow C frozen codetables", () => {
const usages = spawnSync(process.execPath, [binPath, "codes", "usages"], {
encoding: "utf8"
});
assert.equal(usages.status, 0, `stderr: ${usages.stderr}`);
const usagesParsed = JSON.parse(usages.stdout);
assert.ok(
usagesParsed.items.some((item) => item.name === "건물" && item.code === "20000"),
"expected 건물=20000 to come from upstream selectLclLst.on capture"
);
assert.ok(
usagesParsed.items.some((item) => item.name === "토지" && item.code === "10000")
);
const regions = spawnSync(process.execPath, [binPath, "codes", "regions"], {
encoding: "utf8"
});
assert.equal(regions.status, 0, `stderr: ${regions.stderr}`);
const regionsParsed = JSON.parse(regions.stdout);
assert.ok(
regionsParsed.items.some((item) => item.sidoName === "서울특별시" && item.sidoCode === "11"),
"expected 서울특별시=11 to come from upstream selectAdongSdLst.on capture"
);
assert.equal(
regionsParsed.items.length,
19,
"expected all 19 시도 from upstream"
);
});
test("CLI search supports --region and --usage colon-form parsing (Issue #184 Q3)", () => {
const { parseArgs: pa } = require("../src/cli");
const result = pa([
"search",
"--region",
"서울특별시:강남구:역삼동",
"--usage",
"건물:공동주택:아파트",
"--bid-type",
"date"
]);
assert.equal(result.flags.region, "서울특별시:강남구:역삼동");
assert.equal(result.flags.usage, "건물:공동주택:아파트");
});
test("CLI rejects --date with an obviously invalid format", () => {
const result = spawnSync(
process.execPath,

View file

@ -0,0 +1,71 @@
{
"_source": "POST /pgj/pgjsearch/searchControllerMain.on body sent by browser when user clicks 검색 in PGJ151F00 with cortOfcCd=서울중앙(B000210), bidType=date, saleDate=2026-05-08~2026-05-22, no other filters. Captured 2026-05-08 via Playwright. Verified to receive HTTP 200 with real results (totalCnt=350) when re-sent with same session cookies. Discovery script: packages/court-auction-notice-search/scripts/capture-pgj151-submit.cjs",
"dma_pageInfo": {
"pageNo": 1,
"pageSize": 10,
"bfPageNo": "",
"startRowNo": "",
"totalCnt": "",
"totalYn": "Y",
"groupTotalCount": ""
},
"dma_srchGdsDtlSrchInfo": {
"rletDspslSpcCondCd": "",
"bidDvsCd": "000331",
"mvprpRletDvsCd": "00031R",
"cortAuctnSrchCondCd": "0004601",
"rprsAdongSdCd": "",
"rprsAdongSggCd": "",
"rprsAdongEmdCd": "",
"rdnmSdCd": "",
"rdnmSggCd": "",
"rdnmNo": "",
"mvprpDspslPlcAdongSdCd": "",
"mvprpDspslPlcAdongSggCd": "",
"mvprpDspslPlcAdongEmdCd": "",
"rdDspslPlcAdongSdCd": "",
"rdDspslPlcAdongSggCd": "",
"rdDspslPlcAdongEmdCd": "",
"cortOfcCd": "B000210",
"jdbnCd": "",
"execrOfcDvsCd": "",
"lclDspslGdsLstUsgCd": "",
"mclDspslGdsLstUsgCd": "",
"sclDspslGdsLstUsgCd": "",
"cortAuctnMbrsId": "",
"aeeEvlAmtMin": "",
"aeeEvlAmtMax": "",
"lwsDspslPrcRateMin": "",
"lwsDspslPrcRateMax": "",
"flbdNcntMin": "",
"flbdNcntMax": "",
"objctArDtsMin": "",
"objctArDtsMax": "",
"mvprpArtclKndCd": "",
"mvprpArtclNm": "",
"mvprpAtchmPlcTypCd": "",
"notifyLoc": "off",
"lafjOrderBy": "",
"pgmId": "PGJ151F01",
"csNo": "",
"cortStDvs": "1",
"statNum": 1,
"bidBgngYmd": "20260508",
"bidEndYmd": "20260522",
"dspslDxdyYmd": "",
"fstDspslHm": "",
"scndDspslHm": "",
"thrdDspslHm": "",
"fothDspslHm": "",
"dspslPlcNm": "",
"lwsDspslPrcMin": "",
"lwsDspslPrcMax": "",
"grbxTypCd": "",
"gdsVendNm": "",
"fuelKndCd": "",
"carMdyrMax": "",
"carMdyrMin": "",
"carMdlNm": "",
"sideDvsCd": ""
}
}

View file

@ -0,0 +1,13 @@
{
"_source": "POST /pgj/pgj002/selectLclLst.on body={dsignUsgDvsCd:\"\"} captured 2026-05-08 from production courtauction.go.kr (PGJ151F00 page initial XHR). Discovery script: packages/court-auction-notice-search/scripts/capture-pgj151.cjs",
"status": 200,
"message": "대분류 목록 조회가 완료되었습니다.",
"data": {
"usgLclLst": [
{ "name": "토지", "code": "10000" },
{ "name": "건물", "code": "20000" },
{ "name": "차량및운송장비", "code": "30000" },
{ "name": "기타", "code": "40000" }
]
}
}

View file

@ -0,0 +1,216 @@
{
"status": 200,
"message": "검색 결과가 조회되었습니다.",
"timestamp": 0,
"errors": null,
"data": {
"dma_pageInfo": {
"pageNo": 2,
"pageSize": 20,
"bfPageNo": "",
"startRowNo": 1,
"totalCnt": "37",
"totalYn": "Y",
"groupTotalCount": 37
},
"ipcheck": true,
"dlt_srchResult": [
{
"docid": "B0002102022013010528411",
"boCd": "B000210",
"saNo": "20220130105284",
"maemulSer": "1",
"mokmulSer": "1",
"srnSaNo": "2022타경105284",
"jpDeptCd": "1002",
"jinstatCd": "0002100001",
"mulStatcd": "01",
"mulJinYn": "Y",
"maemulUtilCd": "17",
"mulBigo": "일괄매각.",
"gamevalAmt": "450000000",
"minmaePrice": "360000000",
"yuchalCnt": "5",
"maeAmt": "0",
"inqCnt": "58",
"gwansMulRegCnt": "2",
"remaeordDay": "",
"ipchalGbncd": "000331",
"maeGiil": "20260521",
"maegyuljGiil": "20260528",
"maeHh1": "1000",
"maeHh2": "",
"maeHh3": "",
"maeHh4": "",
"notifyMinmaePrice1": "288000000",
"notifyMinmaePrice2": "0",
"notifyMinmaePrice3": "0",
"notifyMinmaePrice4": "0",
"notifyMinmaePriceRate1": "64",
"notifyMinmaePriceRate2": "64",
"maeGiilCnt": "5",
"ipgiganFday": "",
"ipgiganTday": "",
"maePlace": "경매법정(제4별관211호)",
"spJogCd": "",
"mokGbncd": "02",
"jongCd": "000",
"stopsaGbncd": "00",
"daepyoSidoCd": "11",
"daepyoSiguCd": "680",
"daepyoDongCd": "106",
"daepyoRdCd": "00",
"hjguSido": "서울특별시",
"hjguSigu": "강남구",
"hjguDong": "대치동",
"hjguRd": "",
"daepyoLotno": "936-31",
"buldNm": "지하층제5호",
"buldList": "",
"areaList": "",
"jimokList": "",
"lclsUtilCd": "20000",
"mclsUtilCd": "21100",
"sclsUtilCd": "21101",
"jejosaNm": "",
"fuelKindcd": "",
"bsgFormCd": "",
"carNm": "",
"carYrtype": "0",
"xCordi": "316456",
"yCordi": "544337",
"cordiLvl": "1",
"bgPlaceSidoCd": "",
"bgPlaceSiguCd": "",
"bgPlaceDongCd": "",
"bgPlaceRdCd": "",
"bgPlaceLotno": "",
"bgPlaceSido": "",
"bgPlaceSigu": "",
"bgPlaceDong": "",
"bgPlaceRd": "",
"srchHjguBgFlg": "",
"pjbBuldList": "지하층제5내\r\n철근콘크리트조 슬래브 3층 슈퍼마켓\r\n점포및 사무실 1동\r\n내 지하층 제5호\r\n18.50㎡",
"minArea": "18",
"maxArea": "18",
"groupmaemulser": "B000210202201301052841",
"bocdsano": "B00021020220130105284",
"dupSaNo": "2020타경103130<br/>2022타경108245<br/>2024타경113418",
"byungSaNo": "",
"srchLclsUtilCd": "20000",
"srchMclsUtilCd": "21100",
"srchSclsUtilCd": "21101",
"srchHjguSidoCd": "11",
"srchHjguSiguCd": "11680",
"srchHjguDongCd": "11680106",
"srchHjguRdCd": "1168010600",
"srchHjguLotno": "936-33^936-31^936-32",
"jiwonNm": "서울중앙지방법원",
"jpDeptNm": "경매2계",
"tel": "530-1814(제4별관 민사집행과)",
"maejibun": "",
"wgs84Xcordi": "127",
"wgs84Ycordi": "37",
"alias": "budongsanmok"
},
{
"docid": "B0002102023013010600001",
"boCd": "B000210",
"saNo": "20230130106000",
"maemulSer": "1",
"mokmulSer": "1",
"srnSaNo": "2023타경106000",
"jpDeptCd": "1002",
"jinstatCd": "0002100001",
"mulStatcd": "01",
"mulJinYn": "Y",
"maemulUtilCd": "17",
"mulBigo": "일괄매각.",
"gamevalAmt": "1500000000",
"minmaePrice": "1200000000",
"yuchalCnt": "1",
"maeAmt": "0",
"inqCnt": "58",
"gwansMulRegCnt": "2",
"remaeordDay": "",
"ipchalGbncd": "000331",
"maeGiil": "20260520",
"maegyuljGiil": "20260528",
"maeHh1": "1000",
"maeHh2": "",
"maeHh3": "",
"maeHh4": "",
"notifyMinmaePrice1": "288000000",
"notifyMinmaePrice2": "0",
"notifyMinmaePrice3": "0",
"notifyMinmaePrice4": "0",
"notifyMinmaePriceRate1": "64",
"notifyMinmaePriceRate2": "64",
"maeGiilCnt": "5",
"ipgiganFday": "",
"ipgiganTday": "",
"maePlace": "경매법정(제4별관211호)",
"spJogCd": "",
"mokGbncd": "02",
"jongCd": "000",
"stopsaGbncd": "00",
"daepyoSidoCd": "11",
"daepyoSiguCd": "680",
"daepyoDongCd": "106",
"daepyoRdCd": "00",
"hjguSido": "서울특별시",
"hjguSigu": "강남구",
"hjguDong": "역삼동",
"hjguRd": "",
"daepyoLotno": "111-22",
"buldNm": "역삼동 아파트 101동 1502호",
"buldList": "",
"areaList": "",
"jimokList": "",
"lclsUtilCd": "20000",
"mclsUtilCd": "21200",
"sclsUtilCd": "21201",
"jejosaNm": "",
"fuelKindcd": "",
"bsgFormCd": "",
"carNm": "",
"carYrtype": "0",
"xCordi": "316456",
"yCordi": "544337",
"cordiLvl": "1",
"bgPlaceSidoCd": "",
"bgPlaceSiguCd": "",
"bgPlaceDongCd": "",
"bgPlaceRdCd": "",
"bgPlaceLotno": "",
"bgPlaceSido": "",
"bgPlaceSigu": "",
"bgPlaceDong": "",
"bgPlaceRd": "",
"srchHjguBgFlg": "",
"pjbBuldList": "지하층제5내\r\n철근콘크리트조 슬래브 3층 슈퍼마켓\r\n점포및 사무실 1동\r\n내 지하층 제5호\r\n18.50㎡",
"minArea": "18",
"maxArea": "18",
"groupmaemulser": "B000210202201301052841",
"bocdsano": "B00021020220130105284",
"dupSaNo": "2020타경103130<br/>2022타경108245<br/>2024타경113418",
"byungSaNo": "",
"srchLclsUtilCd": "20000",
"srchMclsUtilCd": "21200",
"srchSclsUtilCd": "21201",
"srchHjguSidoCd": "11",
"srchHjguSiguCd": "11680",
"srchHjguDongCd": "11680101",
"srchHjguRdCd": "1168010600",
"srchHjguLotno": "936-33^936-31^936-32",
"jiwonNm": "서울중앙지방법원",
"jpDeptNm": "경매2계",
"tel": "530-1814(제4별관 민사집행과)",
"maejibun": "",
"wgs84Xcordi": "127",
"wgs84Ycordi": "37",
"alias": "budongsanmok"
}
]
}
}

View file

@ -0,0 +1,28 @@
{
"_source": "POST /pgj/pgj002/selectAdongSdLst.on body={pbancMidYn:\"Y\",srchDvsCd:\"B\",pbancDvsCd:\"A\"} captured 2026-05-08 from production courtauction.go.kr (PGJ151F00 page initial XHR). Discovery script: packages/court-auction-notice-search/scripts/capture-pgj151.cjs",
"status": 200,
"message": "시/도 리스트 조회가 완료되었습니다.",
"data": {
"adongSdLst": [
{ "name": "서울특별시", "code": "11", "value": "서울특별시" },
{ "name": "부산광역시", "code": "26", "value": "부산광역시" },
{ "name": "대구광역시", "code": "27", "value": "대구광역시" },
{ "name": "인천광역시", "code": "28", "value": "인천광역시" },
{ "name": "광주광역시", "code": "29", "value": "광주광역시" },
{ "name": "대전광역시", "code": "30", "value": "대전광역시" },
{ "name": "울산광역시", "code": "31", "value": "울산광역시" },
{ "name": "세종특별자치시", "code": "36", "value": "세종특별자치시" },
{ "name": "경기도", "code": "41", "value": "경기도" },
{ "name": "강원도", "code": "42", "value": "강원도" },
{ "name": "충청북도", "code": "43", "value": "충청북도" },
{ "name": "충청남도", "code": "44", "value": "충청남도" },
{ "name": "전라북도", "code": "45", "value": "전라북도" },
{ "name": "전라남도", "code": "46", "value": "전라남도" },
{ "name": "경상북도", "code": "47", "value": "경상북도" },
{ "name": "경상남도", "code": "48", "value": "경상남도" },
{ "name": "제주특별자치도", "code": "50", "value": "제주특별자치도" },
{ "name": "강원특별자치도", "code": "51", "value": "강원특별자치도" },
{ "name": "전북특별자치도", "code": "52", "value": "전북특별자치도" }
]
}
}

View file

@ -9,7 +9,11 @@ const {
searchSaleNotices,
getSaleNoticeDetail,
getCaseByCaseNumber,
searchProperties,
buildPropertySearchBody,
getCourtCodes,
getUsageCodes,
getRegionCodes,
getBidTypes,
resolveBidTypeCode,
describeBidTypeCode,
@ -230,6 +234,294 @@ test("getCaseByCaseNumber returns found:false on status 204", async () => {
assert.match(result.message, /조회 되는 사건번호 정보가 없습니다/);
});
test("buildPropertySearchBody matches the canonical PGJ151M01 body captured from a real browser submission", () => {
const canonical = loadFixture("canonical-search-body.json");
delete canonical._source;
const body = buildPropertySearchBody({
bidType: "date",
courtCode: "B000210",
saleDate: { from: "2026-05-08", to: "2026-05-22" },
page: 1,
pageSize: 10
});
assert.deepEqual(body, canonical);
assert.equal(typeof body.dma_pageInfo.pageNo, "number", "pageNo must be numeric (matches captured body)");
assert.equal(typeof body.dma_pageInfo.pageSize, "number", "pageSize must be numeric");
assert.equal(typeof body.dma_srchGdsDtlSrchInfo.statNum, "number", "statNum must be numeric");
assert.equal(body.dma_srchGdsDtlSrchInfo.cortStDvs, "1", "no region → cortStDvs=1");
assert.equal(body.dma_srchGdsDtlSrchInfo.notifyLoc, "off");
});
test("buildPropertySearchBody maps Workflow C filters using REAL upstream codes (lcl/sigungu/dong)", () => {
const body = buildPropertySearchBody({
region: { sido: "서울특별시", sigungu: "11680", dong: "11680101" },
usage: { large: "건물", medium: "21200", small: "21201" },
priceRange: { min: 100000000, max: 500000000 },
appraisedPriceRange: { min: 150000000, max: 800000000 },
saleDate: { from: "2026-05-01", to: "2026-05-20" },
flbdCount: { min: 1, max: 3 },
area: { min: 30, max: 85.5 },
bidType: "date",
courtCode: "B000210",
page: 2,
pageSize: 20
});
assert.equal(body.dma_pageInfo.pageNo, 2);
assert.equal(body.dma_pageInfo.pageSize, 20);
assert.equal(body.dma_pageInfo.totalYn, "Y");
const s = body.dma_srchGdsDtlSrchInfo;
assert.equal(s.bidDvsCd, "000331");
assert.equal(s.cortOfcCd, "B000210");
assert.equal(s.cortStDvs, "2");
assert.equal(s.rprsAdongSdCd, "11");
assert.equal(s.rprsAdongSggCd, "11680");
assert.equal(s.rprsAdongEmdCd, "11680101");
assert.equal(s.lclDspslGdsLstUsgCd, "20000", "건물 → 20000 (real upstream LCL code)");
assert.equal(s.mclDspslGdsLstUsgCd, "21200");
assert.equal(s.sclDspslGdsLstUsgCd, "21201");
assert.equal(s.lwsDspslPrcMin, "100000000");
assert.equal(s.lwsDspslPrcMax, "500000000");
assert.equal(s.aeeEvlAmtMin, "150000000");
assert.equal(s.aeeEvlAmtMax, "800000000");
assert.equal(s.flbdNcntMin, "1");
assert.equal(s.flbdNcntMax, "3");
assert.equal(s.objctArDtsMin, "30");
assert.equal(s.objctArDtsMax, "85.5");
assert.equal(s.bidBgngYmd, "20260501");
assert.equal(s.bidEndYmd, "20260520");
assert.equal(s.mvprpArtclKndCd, "", "real upstream uses mvprpArtclKndCd not mvprpArtclKnd");
assert.equal(s.mvprpAtchmPlcTypCd, "", "real upstream uses mvprpAtchmPlcTypCd not mvrpDspslPlcTyp");
assert.ok(!Object.prototype.hasOwnProperty.call(s, "mvprpArtclKnd"), "old wrong key removed");
assert.ok(!Object.prototype.hasOwnProperty.call(s, "mvrpDspslPlcTyp"), "old wrong key removed");
assert.ok(!Object.prototype.hasOwnProperty.call(s, "consonant"), "consonant is not in canonical body");
assert.ok(!Object.prototype.hasOwnProperty.call(s, "maeMokmulNm"), "maeMokmulNm is not in canonical body");
assert.equal(s.execrOfcDvsCd, "", "execrOfcDvsCd present (canonical)");
assert.equal(s.cortAuctnMbrsId, "", "cortAuctnMbrsId present (canonical)");
});
test("buildPropertySearchBody keeps the documented raw-code CLI example numeric", () => {
const body = buildPropertySearchBody({
region: { sido: "서울특별시", sigungu: "11680" },
usage: { large: "건물", medium: "21200" },
priceRange: { min: 100000000, max: 500000000 },
saleDate: { from: "2026-05-01", to: "2026-05-20" }
});
const s = body.dma_srchGdsDtlSrchInfo;
assert.equal(s.rprsAdongSdCd, "11");
assert.equal(s.rprsAdongSggCd, "11680");
assert.equal(s.lclDspslGdsLstUsgCd, "20000");
assert.equal(s.mclDspslGdsLstUsgCd, "21200");
assert.equal(s.lwsDspslPrcMin, "100000000");
});
test("buildPropertySearchBody rejects fractional flbdCount (count must be integer)", () => {
assert.throws(
() => buildPropertySearchBody({ flbdCount: { min: 1.5 } }),
/flbdCount\.min .*non-negative integer.*1\.5/
);
});
test("searchProperties posts propertySearch and normalizes the real PGJ151 result row", async () => {
const client = makeFakeClient((endpoint) => {
assert.equal(endpoint, "propertySearch");
return loadFixture("properties-sample.json");
});
const result = await searchProperties({
region: { sido: "11", sigungu: "11680" },
usage: { large: "20000" },
saleDate: { from: "2026-05-01", to: "2026-05-20" },
bidType: "date",
page: 2,
pageSize: 20,
client
});
assert.equal(client.calls.length, 1);
assert.equal(client.calls[0].body.dma_pageInfo.pageNo, 2);
assert.equal(client.calls[0].body.dma_srchGdsDtlSrchInfo.bidBgngYmd, "20260501");
assert.equal(result.page.pageNo, 2);
assert.equal(result.page.pageSize, 20);
assert.equal(result.page.totalCount, 37);
assert.equal(result.count, 2);
const item = result.items[0];
assert.equal(item.caseNumber, "20220130105284");
assert.equal(item.displayCaseNumber, "2022타경105284");
assert.equal(item.itemNumber, "1");
assert.equal(item.itemSeq, "1");
assert.match(item.address, /서울특별시.*강남구.*대치동/);
assert.equal(item.appraisedPrice, 450000000);
assert.equal(item.minimumSalePrice, 360000000);
assert.equal(item.flbdCount, 5);
assert.equal(item.failedBidCount, 5);
assert.equal(item.courtCode, "B000210");
assert.equal(item.courtName, "서울중앙지방법원");
assert.equal(item.judgeDeptName, "경매2계");
assert.deepEqual(item.usageCodes, { large: "20000", medium: "21100", small: "21101" });
assert.deepEqual(item.regionCodes, { sido: "11", sigungu: "11680", dong: "11680106" });
assert.equal(item.saleDate, "2026-05-21");
assert.equal(item.bidTypeCode, "000331");
assert.equal(item.status, item.statusCode);
assert.equal(item.buildings, item.buildingList);
assert.equal(item.areas, item.areaList);
assert.equal(item.lotCategories, item.landCategoryList);
});
test("searchProperties rejects page sizes outside the observed PGJ151 dropdown values", () => {
assert.equal(buildPropertySearchBody({ pageSize: 10 }).dma_pageInfo.pageSize, 10);
assert.equal(buildPropertySearchBody({ pageSize: 20 }).dma_pageInfo.pageSize, 20);
assert.equal(buildPropertySearchBody({ pageSize: 50 }).dma_pageInfo.pageSize, 50);
assert.equal(buildPropertySearchBody({ pageSize: 100 }).dma_pageInfo.pageSize, 100);
assert.throws(
() => buildPropertySearchBody({ pageSize: 1 }),
/pageSize must be one of 10, 20, 50, 100/
);
assert.throws(
() => buildPropertySearchBody({ pageSize: 25 }),
/pageSize must be one of 10, 20, 50, 100/
);
assert.throws(
() => buildPropertySearchBody({ pageSize: 500 }),
/pageSize must be one of 10, 20, 50, 100/
);
});
test("searchProperties falls back from an explicit HTTP client on Workflow C WAF-style HTTP 400", async () => {
const primary = makeFakeClient(() => {
const error = new Error("HTTP 400");
error.code = "UPSTREAM_ERROR";
error.statusCode = 400;
throw error;
});
const fallback = makeFakeClient((endpoint) => {
assert.equal(endpoint, "propertySearch");
return loadFixture("properties-sample.json");
});
const result = await searchProperties({
client: primary,
fallbackClient: fallback,
courtCode: "B000210",
saleDate: { from: "2026-05-08", to: "2026-05-22" },
pageSize: 10,
includeRaw: false
});
assert.equal(primary.calls.length, 1);
assert.equal(fallback.calls.length, 1);
assert.equal(result.items.length, 2);
assert.equal(result.items[0].displayCaseNumber, "2022타경105284");
});
test("searchProperties stops on confirmed BLOCKED responses by default", async () => {
const primary = makeFakeClient(() => {
const error = new Error("ipcheck false");
error.code = "BLOCKED";
throw error;
});
const fallback = makeFakeClient(() => loadFixture("properties-sample.json"));
await assert.rejects(
() =>
searchProperties({
client: primary,
fallbackClient: fallback
}),
/ipcheck false/
);
assert.equal(primary.calls.length, 1);
assert.equal(fallback.calls.length, 0);
});
test("searchProperties only retries confirmed BLOCKED responses with explicit fallbackOnBlocked", async () => {
const primary = makeFakeClient(() => {
const error = new Error("ipcheck false");
error.code = "BLOCKED";
throw error;
});
const fallback = makeFakeClient(() => loadFixture("properties-sample.json"));
const result = await searchProperties({
client: primary,
fallbackClient: fallback,
fallbackOnBlocked: true,
includeRaw: false
});
assert.equal(primary.calls.length, 1);
assert.equal(fallback.calls.length, 1);
assert.equal(result.items.length, 2);
});
test("searchProperties honors fallback:false even for Workflow C HTTP 400", async () => {
const primary = makeFakeClient(() => {
const error = new Error("HTTP 400");
error.code = "UPSTREAM_ERROR";
error.statusCode = 400;
throw error;
});
const fallback = makeFakeClient(() => loadFixture("properties-sample.json"));
await assert.rejects(
() =>
searchProperties({
client: primary,
fallbackClient: fallback,
fallback: false
}),
/HTTP 400/
);
assert.equal(fallback.calls.length, 0);
});
test("Workflow C code tables expose REAL upstream LCL and sido lookups", () => {
const usages = getUsageCodes();
const regions = getRegionCodes();
assert.ok(
usages.items.some((item) => item.code === "20000" && item.name === "건물" && item.level === "large"),
"건물=20000 from upstream selectLclLst.on"
);
assert.ok(
usages.items.some((item) => item.code === "10000" && item.name === "토지"),
"토지=10000 from upstream"
);
assert.ok(
regions.items.some((item) => item.sidoCode === "11" && item.sidoName === "서울특별시"),
"서울특별시=11 from upstream selectAdongSdLst.on"
);
assert.equal(regions.items.length, 19, "all 19 sido from upstream");
});
test("buildPropertySearchBody returns empty region when no region input given", () => {
const empty = buildPropertySearchBody({}).dma_srchGdsDtlSrchInfo;
assert.equal(empty.cortStDvs, "1", "no region → cortStDvs=1");
assert.equal(empty.rprsAdongSdCd, "", "no region → empty sido (no first-row fallback)");
assert.equal(empty.rprsAdongSggCd, "");
assert.equal(empty.rprsAdongEmdCd, "");
});
test("buildPropertySearchBody preserves partial region granularity (sido-only and sido+sigungu)", () => {
const sidoOnly = buildPropertySearchBody({ region: { sido: "서울특별시" } }).dma_srchGdsDtlSrchInfo;
assert.equal(sidoOnly.cortStDvs, "2");
assert.equal(sidoOnly.rprsAdongSdCd, "11");
assert.equal(sidoOnly.rprsAdongSggCd, "");
assert.equal(sidoOnly.rprsAdongEmdCd, "");
const sidoSigungu = buildPropertySearchBody({
region: { sido: "서울특별시", sigungu: "11680" }
}).dma_srchGdsDtlSrchInfo;
assert.equal(sidoSigungu.rprsAdongSdCd, "11");
assert.equal(sidoSigungu.rprsAdongSggCd, "11680");
assert.equal(sidoSigungu.rprsAdongEmdCd, "");
});
test("getCourtCodes hits the courts endpoint and returns code/name pairs", async () => {
const client = makeFakeClient((endpoint) => {
assert.equal(endpoint, "courts");
@ -246,6 +538,7 @@ test("ENDPOINT_PATHS exposes the discovered courtauction.go.kr endpoints", () =>
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");
assert.equal(ENDPOINT_PATHS.propertySearch, "/pgj/pgjsearch/searchControllerMain.on");
});
test("isPlaywrightFallbackAvailable is a boolean (no crash even when modules are absent)", () => {

View file

@ -87,7 +87,7 @@ test("warmup GETs the index page and stores JSESSIONID/WMONID cookies", async ()
});
await client.warmup();
assert.equal(client.warmedUp, true);
assert.equal(client.warmedUp, WARMUP_PATH);
assert.equal(client.cookieJar.get("JSESSIONID"), "abc123");
assert.equal(client.cookieJar.get("WMONID"), "def456");
});

View file

@ -1302,6 +1302,7 @@ test("root pack:dry-run script covers all publishable workspaces", () => {
assert.match(packageJson.scripts["pack:dry-run"], /workspace blue-ribbon-nearby/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace kakao-bar-nearby/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace public-restroom-nearby/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace court-auction-notice-search/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace kbl-results/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace kleague-results/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace lck-analytics/);
@ -3914,3 +3915,62 @@ test("repository docs advertise the k-skill-cleaner skill and agent usage source
assert.match(readme, /\[K-스킬 클리너 가이드\]\(docs\/features\/k-skill-cleaner\.md\)/);
assert.match(install, /--skill k-skill-cleaner/);
});
test("court auction Workflow C docs preserve the PGJ151 safety contract", () => {
const featureDoc = read(path.join("docs", "features", "court-auction-notice-search.md"));
assert.match(
featureDoc,
/물건 자유 조건검색[\s\S]*PGJ151F00/,
"Workflow C docs should name the PGJ151F00 warmup path for property search",
);
assert.match(
featureDoc,
/pageSize[\s\S]*`10`\/`20`\/`50`\/`100`/,
"Workflow C docs should keep pageSize aligned with the observed PGJ151 dropdown values",
);
assert.doesNotMatch(
featureDoc,
/세션 cookie\([^\n]+\)는 `GET \/pgj\/index\.on\?w2xPath=\/pgj\/ui\/pgj100\/PGJ143M01\.xml&pgjId=143M01` 으로 사전에 한 번 받아둡니다\./,
"Workflow C docs should not imply every endpoint warms up through PGJ143M01 only",
);
});
test("court auction pending changeset does not publish stale fallback or pageSize guidance", () => {
const changesetDir = path.join(repoRoot, ".changeset");
if (!fs.existsSync(changesetDir)) return;
const pendingCourtAuctionChangesets = fs
.readdirSync(changesetDir)
.filter((entry) => entry.endsWith(".md"))
.map((entry) => read(path.join(".changeset", entry)))
.filter((contents) => contents.includes('"court-auction-notice-search"') && contents.includes("searchProperties()"));
for (const contents of pendingCourtAuctionChangesets) {
assert.doesNotMatch(
contents,
/direct HTTP call returns `BLOCKED` or `UPSTREAM_ERROR` 400/,
"Changeset must not tell users that confirmed BLOCKED/ipcheck=false auto-falls back by default",
);
assert.doesNotMatch(
contents,
/`pageSize` is capped at 100/,
"Changeset must not describe pageSize as an arbitrary 1..100 cap",
);
assert.match(
contents,
/`UPSTREAM_ERROR` 400/,
"Changeset should still document the raw-HTTP WAF-style 400 fallback path",
);
assert.match(
contents,
/`BLOCKED`[\s\S]*`fallbackOnBlocked:true`/,
"Changeset should document that confirmed BLOCKED retry is explicit opt-in",
);
assert.match(
contents,
/`10`\/`20`\/`50`\/`100`/,
"Changeset should document the exact PGJ151 pageSize allowlist",
);
}
});