Align court auction lookup with monthly site search (#196)

The court auction notice page posts a YYYYMM search key from its 조회 button and returns a month of rows. Keep day inputs as a compatibility filter over the monthly response and normalize the current nested detail payload shape.

Constraint: courtauction.go.kr has no public API and blocks bursty automated calls.

Rejected: querying every day independently | the upstream search surface is month-based and day calls return false empty results.

Confidence: high

Scope-risk: narrow

Directive: Preserve the site-observed YYYYMM notice search contract unless the PGJ143M01 XHR changes again.

Tested: npm --workspace packages/court-auction-notice-search test; npm run ci; live 서울중앙지방법원 2026-05 notice/detail smoke lookup.

Not-tested: PR CI after push.

Co-authored-by: OmX <omx@oh-my-codex.dev>
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-01 22:05:54 +09:00 committed by GitHub
commit a25d641d00
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 130 additions and 34 deletions

View file

@ -0,0 +1,5 @@
---
"court-auction-notice-search": patch
---
Fix sale notice search to post the court site month key (`YYYYMM`) and filter exact-day requests locally; normalize the current nested notice-detail response shape and HTML-formatted prices.

View file

@ -39,7 +39,7 @@ metadata:
## Inputs
- `date` — 매각기일 (YYYY-MM-DD 또는 YYYYMMDD). 필수.
- `date` — 매각기일 월(YYYY-MM 또는 YYYYMM) 또는 특정일(YYYY-MM-DD 또는 YYYYMMDD). 필수. 실제 사이트 검색 버튼은 월(YYYYMM) 단위로 조회하므로 특정일 입력은 월 조회 후 해당 일자만 필터링한다.
- `courtCode` — 법원사무소코드 (예: `B000210` = 서울중앙지방법원). 비우면 전체. `getCourtCodes()` 또는 `codes courts` 로 받아온다.
- `bidType``date` (= 기일입찰, code 000331) 또는 `period` (= 기간입찰, code 000332). 빈값이면 둘 다.
- `caseNumber` — 사건번호. `2024타경100001` 형식 권장. `2024-100001` 도 받아서 `2024타경100001` 로 정규화한다.
@ -147,7 +147,7 @@ court-auction-notice-search codes courts --pretty | head -40
court-auction-notice-search codes bid-types --pretty
# 3. 매각공고 목록
court-auction-notice-search notices --date 2026-04-27 --court-code B000210 --bid-type date --pretty
court-auction-notice-search notices --date 2026-04 --court-code B000210 --bid-type date --pretty
# 4. 매각공고 상세 — list 응답의 row 의 raw 필드를 그대로 detail 호출에 사용한다.
# (CLI 단발 호출에서는 list -> detail 으로 결과를 파이프할 수 있도록 jq 등을 함께 사용)

View file

@ -36,7 +36,7 @@
court-auction-notice-search -h
court-auction-notice-search codes courts --pretty | head -40
court-auction-notice-search codes bid-types --pretty
court-auction-notice-search notices --date 2026-04-27 --court-code B000210 --bid-type date --pretty
court-auction-notice-search notices --date 2026-04 --court-code B000210 --bid-type date --pretty
court-auction-notice-search case --court-code B000210 --case-number "2024타경100001" --pretty
```
@ -50,7 +50,7 @@ const {
} = require("court-auction-notice-search");
const notices = await searchSaleNotices({
date: "2026-04-27",
date: "2026-04", // 월 전체 조회. 일자 입력은 같은 월 조회 후 해당일만 필터링
courtCode: "B000210",
bidType: "date"
});
@ -73,7 +73,7 @@ const caseInfo = await getCaseByCaseNumber({
| 목적 | 메소드 + 경로 | request body |
| --- | --- | --- |
| 매각공고 목록 | `POST /pgj/pgj143/selectRletDspslPbanc.on` | `{"dma_srchDspslPbanc":{"srchYmd","cortOfcCd","bidDvsCd","srchBtnYn":"Y"}}` |
| 매각공고 목록 | `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/pgjComm/selectCortOfcCdLst.on` | `{}` |

View file

@ -47,7 +47,7 @@ console.log(courts.items.find((c) => c.name === "서울중앙지방법원"));
// { code: "B000210", name: "서울중앙지방법원", branchName: "서울중앙지방법원" }
const notices = await searchSaleNotices({
date: "2026-04-27",
date: "2026-04", // 월 전체 조회. "2026-04-27"처럼 일자를 주면 같은 월을 조회한 뒤 해당 일자만 필터링
courtCode: "B000210",
bidType: "date" // "기일입찰" / "000331" 도 모두 받음
});
@ -76,14 +76,14 @@ 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 notices --date 2026-04-27 --court-code B000210 --bid-type date --pretty
court-auction-notice-search notices --date 2026-04 --court-code B000210 --bid-type date --pretty
court-auction-notice-search case --court-code B000210 --case-number "2024타경100001" --pretty
```
## Public API
- `searchSaleNotices({ date, courtCode?, bidType?, includeRaw?, client? })`
- `date`: `"YYYY-MM-DD"` 또는 `"YYYYMMDD"` (필수)
- `date`: `"YYYY-MM"`/`"YYYYMM"` 또는 `"YYYY-MM-DD"`/`"YYYYMMDD"` (필수). 실제 사이트 검색 버튼은 월(`YYYYMM`) 단위로 조회하므로, 일자를 주면 같은 월을 조회한 뒤 해당 매각기일만 필터링한다
- `courtCode`: `"B000210"` 형식 또는 `""`(전체)
- `bidType`: `"date"` / `"period"` / `"기일입찰"` / `"기간입찰"` / `"000331"` / `"000332"` / `""`
- returns `{ requestedDate, requestedCourtCode, requestedBidType, count, items[] }`
@ -133,7 +133,7 @@ discovery 시 직접 캡처한 사이트 내부 endpoint:
| 목적 | 메소드 + 경로 | request body 핵심 키 |
| --- | --- | --- |
| 매각공고 목록 | `POST /pgj/pgj143/selectRletDspslPbanc.on` | `dma_srchDspslPbanc.{srchYmd, cortOfcCd, bidDvsCd, srchBtnYn:"Y"}` |
| 매각공고 목록 | `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/pgjComm/selectCortOfcCdLst.on` | `{}` |

View file

@ -36,6 +36,31 @@ function toYmd(input, label) {
return compact;
}
function toNoticeSearchDate(input, label) {
if (input === null || input === undefined || input === "") {
throw new Error(`${label} is required (YYYY-MM, YYYYMM, YYYY-MM-DD, or YYYYMMDD)`);
}
const value = String(input).trim();
const compact = value.replace(/[^0-9]/g, "");
if (/^\d{6}$/.test(compact)) {
return { queryYmd: compact, exactYmd: null };
}
if (/^\d{8}$/.test(compact)) {
return { queryYmd: compact.slice(0, 6), exactYmd: compact };
}
throw new Error(`${label} must be YYYY-MM, YYYYMM, YYYY-MM-DD or YYYYMMDD, got "${input}"`);
}
function formatCompactMonth(value) {
return `${value.slice(0, 4)}-${value.slice(4, 6)}`;
}
function formatCompactDate(value) {
return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`;
}
function normalizeCaseNumber(input) {
if (input === null || input === undefined) {
throw new Error("caseNumber is required (e.g. 2024타경100001)");
@ -88,7 +113,7 @@ function ensureClient(client, options) {
}
async function searchSaleNotices(params = {}) {
const date = toYmd(params.date, "date");
const searchDate = toNoticeSearchDate(params.date, "date");
const courtCodeRaw =
params.courtCode === undefined || params.courtCode === null ? "" : String(params.courtCode).trim();
const courtCode = courtCodeRaw === "" ? "" : ensureCourtCode(courtCodeRaw);
@ -97,7 +122,9 @@ async function searchSaleNotices(params = {}) {
const client = ensureClient(params.client, params);
const body = {
dma_srchDspslPbanc: {
srchYmd: date,
// The PGJ143M01 "검색" button posts a month key (YYYYMM), not a day key.
// Day-level API compatibility is preserved by filtering the returned month rows below.
srchYmd: searchDate.queryYmd,
cortOfcCd: courtCode,
bidDvsCd: bidTypeCode,
srchBtnYn: "Y"
@ -105,14 +132,27 @@ async function searchSaleNotices(params = {}) {
};
const raw = await client.postJson("notices", body);
return normalizeNoticeListResponse(raw, {
requestedDate: `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}`,
const normalized = normalizeNoticeListResponse(raw, {
requestedDate: searchDate.exactYmd
? formatCompactDate(searchDate.exactYmd)
: formatCompactMonth(searchDate.queryYmd),
requestedMonth: formatCompactMonth(searchDate.queryYmd),
requestedCourtCode: courtCode || null,
requestedBidType: bidTypeCode
? { code: bidTypeCode, name: describeBidTypeCode(bidTypeCode) }
: null,
includeRaw: params.includeRaw !== false
});
if (searchDate.exactYmd) {
normalized.items = normalized.items.filter((item) => {
const rawYmd = item.raw && item.raw.dspslDxdyYmd ? String(item.raw.dspslDxdyYmd) : "";
return rawYmd === searchDate.exactYmd;
});
normalized.count = normalized.items.length;
}
return normalized;
}
function pickNoticeKeys(notice) {

View file

@ -8,13 +8,29 @@ function nullIfBlank(value) {
return trimmed === "" ? null : trimmed;
}
function stripHtml(value) {
if (value === null || value === undefined) return null;
const text = String(value)
.replace(/<img\b[^>]*>/gi, " ")
.replace(/<br\s*\/?\s*>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/\s+/g, " ")
.trim();
return text === "" ? null : text;
}
function parseAmount(value) {
if (value === null || value === undefined) return null;
const trimmed = String(value).trim();
if (trimmed === "") return null;
const cleaned = trimmed.replace(/[, ]/g, "").replace(/원$/, "");
if (cleaned === "" || cleaned === "-") return null;
const num = Number(cleaned);
const stripped = stripHtml(value);
if (!stripped) return null;
if (stripped === "-") return null;
const amountMatch = stripped.match(/\d{1,3}(?:,\d{3})+|\d+/);
if (!amountMatch) return null;
const num = Number(amountMatch[0].replace(/[, ]/g, ""));
return Number.isFinite(num) ? num : null;
}
@ -49,6 +65,7 @@ function normalizeNoticeListResponse(rawPayload, options = {}) {
return {
requestedDate: options.requestedDate || null,
requestedMonth: options.requestedMonth || null,
requestedCourtCode: options.requestedCourtCode || null,
requestedBidType: options.requestedBidType || null,
count: items.length,
@ -98,9 +115,23 @@ function collectSaleTimes(row) {
function normalizeNoticeDetailResponse(rawPayload, options = {}) {
const data = rawPayload && typeof rawPayload === "object" ? rawPayload.data : null;
const meta = data && typeof data.dma_srchGnrlPbanc === "object" ? data.dma_srchGnrlPbanc : {};
const resultData = data && typeof data.result === "object" ? data.result : null;
const nestedInput = resultData && typeof resultData.inputData === "object" ? resultData.inputData : null;
const meta =
nestedInput ||
(data && typeof data.dma_srchGnrlPbanc === "object" ? data.dma_srchGnrlPbanc : {});
const nestedPbanc =
resultData &&
resultData.dspslPbanc &&
typeof resultData.dspslPbanc.pbancInfo === "object"
? resultData.dspslPbanc.pbancInfo
: null;
const list =
data && Array.isArray(data.dlt_gnrlPbancLst) ? data.dlt_gnrlPbancLst : [];
nestedPbanc && Array.isArray(nestedPbanc.lst)
? nestedPbanc.lst
: data && Array.isArray(data.dlt_gnrlPbancLst)
? data.dlt_gnrlPbancLst
: [];
const includeRaw = options.includeRaw !== false;
@ -110,7 +141,7 @@ function normalizeNoticeDetailResponse(rawPayload, options = {}) {
bidStartDate: formatYmd(meta.bidBgngYmd),
bidEndDate: formatYmd(meta.bidEndYmd),
judgeDeptCode: nullIfBlank(meta.jdbnCd),
judgeDeptName: nullIfBlank(meta.cortAuctnJdbnNm),
judgeDeptName: nullIfBlank(meta.cortAuctnJdbnNm) || nullIfBlank(nestedPbanc && nestedPbanc.chargDept),
judgeDeptPhone: nullIfBlank(meta.jdbnTelno),
salePlace: nullIfBlank(meta.dspslPlcNm),
saleTimes: collectSaleTimes(meta),
@ -126,7 +157,9 @@ function normalizeNoticeDetailResponse(rawPayload, options = {}) {
items
};
if (includeRaw) {
result.raw = { dma_srchGnrlPbanc: { ...meta } };
result.raw = nestedPbanc
? { inputData: { ...meta }, pbancInfo: { ...nestedPbanc } }
: { dma_srchGnrlPbanc: { ...meta } };
}
return result;
}
@ -134,13 +167,13 @@ function normalizeNoticeDetailResponse(rawPayload, options = {}) {
function normalizeNoticeDetailRow(rawRow, includeRaw) {
const row = ensureRow(rawRow);
const out = {
caseNumber: nullIfBlank(row.csNo),
caseNumber: stripHtml(row.csNo),
itemSeq: nullIfBlank(row.dspslSeq),
usage: nullIfBlank(row.usgNm),
address: nullIfBlank(row.st),
usage: stripHtml(row.usgNm),
address: stripHtml(row.st),
appraisedPrice: parseAmount(row.aeeEvlAmt),
minimumSalePrice: parseAmount(row.lwsDspslPrc),
remarks: nullIfBlank(row.dspslRmk)
remarks: stripHtml(row.dspslRmk)
};
if (includeRaw) {
out.raw = { ...row };
@ -319,6 +352,7 @@ module.exports = {
normalizeCourtCodesResponse,
normalizeCaseDetailResponse,
parseAmount,
stripHtml,
formatYmd,
formatHm
};

View file

@ -73,7 +73,7 @@ test("CLI rejects --date with an obviously invalid format", () => {
{ encoding: "utf8" }
);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /must be YYYY-MM-DD or YYYYMMDD/);
assert.match(result.stderr, /must be YYYY-MM, YYYYMM, YYYY-MM-DD or YYYYMMDD/);
});
test("CLI prints usage and exits non-zero on unknown command", () => {

View file

@ -67,7 +67,7 @@ test("describeBidTypeCode returns the Korean name", () => {
assert.equal(describeBidTypeCode(""), "");
});
test("searchSaleNotices builds the expected request body and normalizes the response", async () => {
test("searchSaleNotices posts the month key used by the site search button and normalizes the response", async () => {
const client = makeFakeClient((endpoint) => {
assert.equal(endpoint, "notices");
return loadFixture("notices-sample.json");
@ -83,7 +83,7 @@ test("searchSaleNotices builds the expected request body and normalizes the resp
assert.equal(client.calls.length, 1);
assert.deepEqual(client.calls[0].body, {
dma_srchDspslPbanc: {
srchYmd: "20260427",
srchYmd: "202604",
cortOfcCd: "B000210",
bidDvsCd: "000331",
srchBtnYn: "Y"
@ -92,20 +92,33 @@ test("searchSaleNotices builds the expected request body and normalizes the resp
assert.equal(result.count, 2);
assert.equal(result.requestedDate, "2026-04-27");
assert.equal(result.requestedMonth, "2026-04");
assert.equal(result.requestedCourtCode, "B000210");
assert.deepEqual(result.requestedBidType, { code: "000331", name: "기일입찰" });
assert.equal(result.items[0].caseNumber, undefined);
assert.equal(result.items[0].noticeId, "REAL_ID_2026042701");
});
test("searchSaleNotices accepts compact YYYYMMDD dates and rejects garbage", async () => {
const client = makeFakeClient(() => loadFixture("notices-empty.json"));
await searchSaleNotices({ date: "20260427", client });
assert.equal(client.calls[0].body.dma_srchDspslPbanc.srchYmd, "20260427");
test("searchSaleNotices accepts compact dates/months and filters exact day requests", async () => {
const client = makeFakeClient(() => loadFixture("notices-sample.json"));
const exactDay = await searchSaleNotices({ date: "20260427", client });
assert.equal(client.calls[0].body.dma_srchDspslPbanc.srchYmd, "202604");
assert.equal(exactDay.count, 2);
const emptyDay = await searchSaleNotices({ date: "2026-04-28", client });
assert.equal(client.calls[1].body.dma_srchDspslPbanc.srchYmd, "202604");
assert.equal(emptyDay.count, 0);
const month = await searchSaleNotices({ date: "2026-04", client });
assert.equal(client.calls[2].body.dma_srchDspslPbanc.srchYmd, "202604");
assert.equal(month.requestedDate, "2026-04");
assert.equal(month.requestedMonth, "2026-04");
assert.equal(month.count, 2);
await assert.rejects(
() => searchSaleNotices({ date: "not-a-date", client }),
/must be YYYY-MM-DD or YYYYMMDD/
/must be YYYY-MM, YYYYMM, YYYY-MM-DD or YYYYMMDD/
);
});

View file

@ -31,6 +31,10 @@ test("parseAmount strips commas/won/spaces and rejects non-numeric", () => {
assert.equal(parseAmount("1,500,000,000"), 1500000000);
assert.equal(parseAmount("1,500,000,000원"), 1500000000);
assert.equal(parseAmount(" 850000000 "), 850000000);
assert.equal(
parseAmount('<img src="/images/number_01.gif" alt="첫번째"> 9,600,000<br>입찰시간(10:00)<br>'),
9600000
);
assert.equal(parseAmount(""), null);
assert.equal(parseAmount("-"), null);
assert.equal(parseAmount("not-a-number"), null);