mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
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:
parent
dc9a765e2a
commit
f527515932
22 changed files with 1546 additions and 45 deletions
18
.changeset/add-court-auction-property-search.md
Normal file
18
.changeset/add-court-auction-property-search.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 이슈로 분리되어 추적됩니다.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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": "전북특별자치도" }
|
||||
]
|
||||
}
|
||||
|
|
@ -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": "아파트" }
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
71
packages/court-auction-notice-search/test/fixtures/canonical-search-body.json
vendored
Normal file
71
packages/court-auction-notice-search/test/fixtures/canonical-search-body.json
vendored
Normal 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": ""
|
||||
}
|
||||
}
|
||||
13
packages/court-auction-notice-search/test/fixtures/lcl-codes-raw.json
vendored
Normal file
13
packages/court-auction-notice-search/test/fixtures/lcl-codes-raw.json
vendored
Normal 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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
216
packages/court-auction-notice-search/test/fixtures/properties-sample.json
vendored
Normal file
216
packages/court-auction-notice-search/test/fixtures/properties-sample.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
28
packages/court-auction-notice-search/test/fixtures/sido-codes-raw.json
vendored
Normal file
28
packages/court-auction-notice-search/test/fixtures/sido-codes-raw.json
vendored
Normal 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": "전북특별자치도" }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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)", () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue