Restore SH notice lookup without proxy policy drift

Reintroduce SH notice search as a direct public HTML client so the skill complies with the free-API proxy boundary while preserving verifiable keyword, pagination, and attachment behavior.

Constraint: i-sh.co.kr board is public unauthenticated HTML, so k-skill-proxy must not host the scraper.\nRejected: Re-adding /v1/sh-notice proxy routes | public HTML scraping in proxy violates repository policy.\nConfidence: high\nScope-risk: moderate\nDirective: Keep SH public HTML access local/direct unless a key-required official free API is discovered and documented.\nTested: npm run ci; npm run lint --workspace sh-notice-search; npm test --workspace sh-notice-search; live SH smoke for 행복주택, 매입임대, 신혼희망타운, page 1/page 5, 1/6/9/11/0 attachment details.\nNot-tested: authenticated SH flows, 청약 application/submission, direct attachment downloads.
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-15 19:22:55 +09:00
commit c83e194a84
11 changed files with 1115 additions and 1 deletions

View file

@ -0,0 +1,5 @@
---
"sh-notice-search": minor
---
Add a policy-compliant SH public notice search skill and direct HTML lookup client.

View file

@ -44,6 +44,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 한국 개인정보처리방침·이용약관 자동 생성 | `korean-privacy-terms` | Next.js 프로젝트에 개인정보보호법·약관규제법·전자상거래법 기반 개인정보처리방침/이용약관/쿠키 배너/동의 모달을 생성하는 `kimlawtech/korean-privacy-terms` (Apache-2.0) thin wrapper | 불필요 | [한국 개인정보처리방침·이용약관 자동 생성 가이드](docs/features/korean-privacy-terms.md) |
| 한국 부동산 실거래가 조회 | `real-estate-search` | 아파트/오피스텔/빌라/단독주택 실거래가·전월세·지역코드 조회 | 불필요 | [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md) |
| 개별공시지가 조회 | `gongsijiga-search` | realtyprice.kr 공개 API에서 지번 단위 개별공시지가(원/㎡) 다년도 추이·전년 대비 변동률 조회 | 불필요 | [개별공시지가 조회 가이드](docs/features/gongsijiga-search.md) |
| SH 청약·주택 공고문 조회 | `sh-notice-search` | 서울주택도시개발공사(SH) 공개 공고/공지 게시판을 직접 조회해 키워드·공고 종류별 목록, 상세 본문, 첨부 미리보기 메타데이터 확인 | 불필요 | [SH 청약·주택 공고문 조회 가이드](docs/features/sh-notice-search.md) |
| LH 청약 공고문 조회 | `lh-notice-search` | 한국토지주택공사(LH) 임대/분양/주거복지(신혼희망타운)/토지/상가 공고를 지역·상태·공고유형·키워드로 조회하고 마감 여부를 KST 기준으로 표시 | 불필요 | [LH 청약 공고문 조회 가이드](docs/features/lh-notice-search.md) |
| 법원 경매 부동산 매각공고 조회 | `court-auction-notice-search` | 대법원경매정보(courtauction.go.kr) 부동산 매각공고를 매각기일·법원·기일/기간 입찰 조건으로 검색해 사건번호·용도·주소·감정평가액·최저매각가격을 펼치고, 사건번호로 직접 사건정보·물건내역·매각기일이력을 조회 | 불필요 | [법원 경매 부동산 매각공고 조회 가이드](docs/features/court-auction-notice-search.md) |
| 기부처 조회 | `donation-place-search` | 지역·관심 분야 기준 기부처 후보와 공식 페이지/1365 확인용 검색 링크 안내 (기부·결제 자동화 제외) | 불필요 | [기부처 조회 가이드](docs/features/donation-place-search.md) |
@ -155,6 +156,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md)
- [개별공시지가 조회 가이드](docs/features/gongsijiga-search.md)
- [LH 청약 공고문 조회 가이드](docs/features/lh-notice-search.md)
- [SH 청약·주택 공고문 조회 가이드](docs/features/sh-notice-search.md)
- [법원 경매 부동산 매각공고 조회 가이드](docs/features/court-auction-notice-search.md)
- [장학금 검색 및 조회 가이드](docs/features/korean-scholarship-search.md)
- [생활쓰레기 배출정보 조회 가이드](docs/features/household-waste-info.md)

View file

@ -0,0 +1,96 @@
# SH 청약·주택 공고문 조회 가이드
`sh-notice-search`는 서울주택도시개발공사(SH, `www.i-sh.co.kr`)의 공개 **공고 및 공지** HTML 게시판을 직접 조회하는 read-only 스킬이다. upstream이 인증/키 없이 열려 있는 공개 표면이므로 `k-skill-proxy`를 사용하지 않는다.
## 이 기능으로 할 수 있는 일
- SH 최신 공고/공지 목록 조회
- 키워드 검색: `행복주택`, `매입임대`, `신혼희망타운`
- 공고 종류 필터: 주택임대, 주택분양, 주택매입(주거복지 alias), 토지, 상가/공장 등
- 페이지네이션: SH 고정 10건 페이지에서 `page`로 이동
- 상세 조회: 본문 텍스트, 담당부서, 등록일, 조회수, 공식 상세 URL
- 첨부 메타데이터: 실제 `existFile()` 첨부 앵커와 `downList` 기반 파일명/미리보기 URL
## 가장 중요한 정책 경계
- SH 게시판은 공개 HTML이라 proxy에 넣지 않는다.
- 별도 API key가 필요한 공식 무료 API가 발견되는 경우에만 해당 경로를 좁은 allowlist proxy route로 검토한다.
- 본 구현은 청약 신청, 로그인, 서류 제출, 결제, 마이페이지 자동화를 하지 않는다.
## 공개 접근 경로
기본 임대 게시판:
```text
https://www.i-sh.co.kr/app/lay2/program/S1T294C297/www/brd/m_247/list.do?multi_itm_seq=2
```
상세:
```text
https://www.i-sh.co.kr/app/lay2/program/S1T294C297/www/brd/m_247/view.do?multi_itm_seq=2&seq=<seq>
```
검색 파라미터:
| 목적 | 파라미터 |
| --- | --- |
| 제목 검색 | `srchWord=<검색어>&srchTp=0` |
| 내용 검색 | `srchWord=<검색어>&srchTp=1` |
| 페이지 | `page=<번호>` |
| 분류 | 공식 탭별 `multi_itm_seq` 및 board path |
SH 게시판은 `srchWord`만 보내면 검색어를 무시하고 전체 목록을 반환할 수 있으므로, 패키지는 키워드가 있을 때 `srchTp`를 반드시 보낸다.
## 사용 예시
```bash
node packages/sh-notice-search/src/cli.js 행복주택 --category 임대 --limit 5
node packages/sh-notice-search/src/cli.js 매입임대 --category 주거복지 --page 2
node packages/sh-notice-search/src/cli.js --seq 304371 --category 임대
```
```js
const { searchNotices, getNoticeDetail } = require("sh-notice-search")
const list = await searchNotices({ keyword: "행복주택", category: "임대", page: 1 })
const detail = await getNoticeDetail({ seq: list.items[0].seq, category: "임대" })
```
## 출력 필드
목록:
- `seq`, `title`, `department`, `registered_date`, `views`
- `category`, `category_name`
- `status` / `status_basis` (제목 기반 보수적 분류)
- `detail_url`
상세:
- `content_text`
- `attachments[]`: `filename`, `file_seq`, `file_size`, `file_type`, `preview_url`
- `detail_url`
직접 다운로드 URL은 노출하지 않고, 공식 상세/미리보기 URL을 사용자 브라우저로 handoff한다.
## 상태와 공고 종류 필터
공고 종류는 SH 공식 탭과 일치하는 board path를 사용한다. `주거복지`는 공개 탭명이 아니므로 사용자 alias로만 받고 현재 SH의 `주택매입` 탭에 매핑한다.
상태(`진행`, `마감`, `당첨자`)는 공개 목록에 별도 컬럼이 없어 제목 텍스트 기반으로만 보수적으로 분류한다. 정확한 접수기간/마감일은 상세 본문이나 첨부 공고문을 확인해야 한다.
## 실패 모드
- SH HTML 구조, board path, `getDetailView()`, `existFile()`, `downList` 구조 변경
- IP rate limit, NetFunnel queue/throttle, 점검 페이지, CAPTCHA/login wall
- 첨부 미리보기/다운로드 direct-link 정책 변경
- `pageSize`를 10보다 크게 지정해도 SH는 한 페이지 10건만 제공
- 상태 분류는 제목 추론이라 상세 공고문 날짜와 다를 수 있음
## Done when
- 직접 공개 SH URL에서 목록/상세를 조회했다.
- 키워드 검색에 `srchTp`가 포함되어 의도된 hit count로 좁혀졌다.
- 페이지가 필요한 경우 `page`를 사용했다.
- 첨부가 아이콘 템플릿이 아니라 실제 `existFile()` 기준으로 추출되었다.

14
package-lock.json generated
View file

@ -1561,6 +1561,10 @@
"version": "2.7.2",
"license": "MIT"
},
"node_modules/sh-notice-search": {
"resolved": "packages/sh-notice-search",
"link": true
},
"node_modules/shebang-command": {
"version": "2.0.0",
"dev": true,
@ -1906,6 +1910,16 @@
"node": ">=18"
}
},
"packages/sh-notice-search": {
"version": "0.1.0",
"license": "MIT",
"bin": {
"sh-notice-search": "src/cli.js"
},
"engines": {
"node": ">=18"
}
},
"packages/toss-securities": {
"version": "0.3.0",
"license": "MIT",

View file

@ -13,7 +13,7 @@
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.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/nts_business_registration.py scripts/test_nts_business_registration.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 nts-business-registration/scripts/nts_business_registration.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 scripts/ticket_availability.py scripts/test_ticket_availability.py ticket-availability/scripts/ticket_availability.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 danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.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 scripts/test_build_manus_bundle.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_nts_business_registration 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 scripts.test_ticket_availability && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts python3 -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.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 court-auction-notice-search --dry-run && npm pack --workspace donation-place-search --dry-run && npm pack --workspace gongsijiga-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 && npm pack --workspace korean-marathon-schedule --dry-run && npm pack --workspace gangnamunni-clinic-search --dry-run && npm pack --workspace daishin-report-search --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 donation-place-search --dry-run && npm pack --workspace gongsijiga-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 && npm pack --workspace korean-marathon-schedule --dry-run && npm pack --workspace gangnamunni-clinic-search --dry-run && npm pack --workspace daishin-report-search --dry-run && npm pack --workspace sh-notice-search --dry-run",
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
"version-packages": "changeset version",
"release:npm": "changeset publish"

View file

@ -0,0 +1,41 @@
# sh-notice-search
Public SH(서울주택도시개발공사) notice lookup client for the `sh-notice-search` k-skill.
## Source
- List/detail pages: `https://www.i-sh.co.kr/app/lay2/program/.../www/brd/.../{list,view}.do`
- Default category: `주택임대` (`multi_itm_seq=2`)
- Keyword search: SH requires both `srchWord` and `srchTp`; this client defaults keyword searches to title scope (`srchTp=0`).
This is an unauthenticated public HTML surface. No proxy or API key is required. The client does not automate application, login, document submission, payment, or My Page flows.
## Usage
```js
const { searchNotices, getNoticeDetail } = require("sh-notice-search")
const list = await searchNotices({ keyword: "행복주택", category: "임대", page: 1 })
const detail = await getNoticeDetail({ seq: list.items[0].seq, category: "임대" })
```
CLI:
```bash
sh-notice-search 행복주택 --category 임대 --limit 5
sh-notice-search 매입임대 --category 주거복지 --status 진행
sh-notice-search --seq 304371 --category 임대
```
## Returned fields
List rows include `seq`, `title`, `department`, `registered_date`, `views`, `category`, `status`, and the official `detail_url`.
Detail rows include `content_text` plus attachment metadata: `filename`, `file_seq`, `file_size`, `file_type`, and official SH `preview_url`. Direct download URLs are intentionally not exposed because SH file-download behavior can be session/policy dependent; hand off official preview/detail URLs to the user's browser.
## Boundaries
- `pageSize`/`limit` is capped at 10 because the SH board returns a fixed 10 rows per page.
- Status filtering uses a conservative title-text classifier because the public board list has no first-class status field.
- Category aliases map to official board tabs (`주택임대`, `주택분양`, `주택매입`, `토지`, etc.). The `주거복지` alias maps to SH's public `주택매입` tab.
- Public HTML structure, NetFunnel/rate limits, and attachment preview policy can change.

View file

@ -0,0 +1,36 @@
{
"name": "sh-notice-search",
"version": "0.1.0",
"description": "Public SH Seoul Housing notice lookup client for k-skill",
"license": "MIT",
"main": "src/index.js",
"bin": {
"sh-notice-search": "src/cli.js"
},
"files": [
"src",
"README.md"
],
"engines": {
"node": ">=18"
},
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/NomaDamas/k-skill.git"
},
"keywords": [
"k-skill",
"sh",
"seoul",
"housing",
"notices",
"korea"
],
"scripts": {
"lint": "node --check src/index.js && node --check src/cli.js && node --check test/index.test.js",
"test": "node --test"
}
}

View file

@ -0,0 +1,72 @@
#!/usr/bin/env node
const { getNoticeDetail, searchNotices } = require("./index")
async function main(options = parseArgs(process.argv.slice(2)), io = console) {
const result = options.seq || options.id || options.noticeSeq
? await getNoticeDetail(options)
: await searchNotices(options)
io.log(JSON.stringify(result, null, 2))
}
function parseArgs(argv) {
const options = {}
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]
if (arg === "--query" || arg === "-q" || arg === "--keyword") options.keyword = argv[++i] || ""
else if (arg === "--category" || arg === "--kind") options.category = argv[++i] || ""
else if (arg === "--status") options.status = argv[++i] || ""
else if (arg === "--page") options.page = argv[++i] || ""
else if (arg === "--limit" || arg === "--page-size") options.limit = argv[++i] || ""
else if (arg === "--srch-tp" || arg === "--search-type") options.searchType = argv[++i] || ""
else if (arg === "--seq" || arg === "--id") options.seq = argv[++i] || ""
else if (arg === "--include-html") options.includeHtml = true
else if (arg === "--help" || arg === "-h") {
printHelp()
process.exit(0)
} else if (/^\d{4,}$/.test(arg) && !options.seq && (argv[i - 1] === "detail" || argv[i - 1] === "--detail")) {
options.seq = arg
} else if (arg === "detail" || arg === "--detail") {
// marker only; following numeric argument can be seq
} else if (!options.keyword) {
options.keyword = arg
}
}
return options
}
function printHelp() {
console.log(`Usage: sh-notice-search [keyword] [options]
Search public SH notices:
sh-notice-search 행복주택 --category 임대 --limit 5
sh-notice-search 매입임대 --category 주거복지 --status 진행
Fetch one detail:
sh-notice-search --seq 304371 --category 임대
Options:
-q, --query <text> Keyword. Defaults to title search when present.
--search-type <type> title/제목 or content/내용.
--category <category> all, rent/임대, sale/분양, welfare/주거복지, land/토지, etc.
--status <status> open/진행, closed/마감, announced/당첨자 (title classifier).
--page <number> Page number (default: 1).
--limit <number> Returned rows; capped at SH fixed page size 10.
--seq <number> Fetch detail by SH notice seq.
--include-html Include raw HTML in output for diagnostics.
`)
}
function formatError(error) {
return error && error.stack ? error.stack : String(error)
}
function run(argv = process.argv.slice(2), io = console) {
return main(parseArgs(argv), io).catch((error) => {
io.error(formatError(error))
process.exitCode = 1
})
}
if (require.main === module) run()
module.exports = { parseArgs, printHelp, formatError, main, run }

View file

@ -0,0 +1,509 @@
const SH_BASE_URL = "https://www.i-sh.co.kr"
const DEFAULT_CATEGORY = "rent"
const DEFAULT_PAGE_SIZE = 10
const MAX_PAGE_SIZE = 10
const DEFAULT_TIMEOUT_MS = 20000
const CATEGORY_CONFIGS = {
all: {
key: "all",
name: "전체",
path: "/app/lay2/program/S1T294C295/www/brd/m_241",
multiItmSeqs: "1,2,4,8,16,32,64,128,256,512",
aliases: ["all", "전체"]
},
sale: {
key: "sale",
name: "주택분양",
path: "/app/lay2/program/S1T294C296/www/brd/m_244",
multiItmSeq: "1",
aliases: ["sale", "분양", "주택분양", "분양주택"]
},
rent: {
key: "rent",
name: "주택임대",
path: "/app/lay2/program/S1T294C297/www/brd/m_247",
multiItmSeq: "2",
aliases: ["rent", "임대", "주택임대", "임대주택"]
},
purchase: {
key: "purchase",
name: "주택매입",
path: "/app/lay2/program/S1T294C3379/www/brd/m_247",
multiItmSeq: "512",
aliases: ["purchase", "매입", "주택매입", "매입임대", "welfare", "주거복지"]
},
movein: {
key: "movein",
name: "입주안내",
path: "/app/lay2/program/S1T294C298/www/brd/m_248",
multiItmSeq: "4",
aliases: ["movein", "입주", "입주안내"]
},
land: {
key: "land",
name: "토지",
path: "/app/lay2/program/S1T294C299/www/brd/m_255",
multiItmSeq: "8",
aliases: ["land", "토지"]
},
commercial: {
key: "commercial",
name: "상가/공장",
path: "/app/lay2/program/S1T294C300/www/brd/m_256",
multiItmSeq: "16",
aliases: ["commercial", "상가", "공장", "상가/공장"]
},
compensation: {
key: "compensation",
name: "보상/이주",
path: "/app/lay2/program/S1T294C301/www/brd/m_257",
multiItmSeq: "32",
aliases: ["compensation", "보상", "이주", "보상/이주"]
},
design: {
key: "design",
name: "현상설계",
path: "/app/lay2/program/S1T294C302/www/brd/m_258",
multiItmSeq: "64",
aliases: ["design", "현상설계", "설계"]
},
etc: {
key: "etc",
name: "기타",
path: "/app/lay2/program/S1T294C304/www/brd/m_260",
multiItmSeq: "256",
aliases: ["etc", "기타"]
}
}
const CATEGORY_ALIAS = Object.fromEntries(
Object.values(CATEGORY_CONFIGS).flatMap((config) => config.aliases.map((alias) => [normalizeToken(alias), config.key]))
)
const STATUS_ALIASES = {
open: "open",
ongoing: "open",
active: "open",
"진행": "open",
"공고중": "open",
"모집중": "open",
closed: "closed",
close: "closed",
ended: "closed",
"마감": "closed",
"종료": "closed",
"결과": "closed",
announced: "announced",
"발표": "announced",
"당첨": "announced",
"당첨자": "announced"
}
function normalizeToken(value) {
return String(value == null ? "" : value).replace(/\s+/g, "").trim().toLowerCase()
}
function cleanText(value) {
return decodeHtml(String(value == null ? "" : value).replace(/\s+/g, " ").trim())
}
function trimOrNull(value) {
const text = cleanText(value)
return text || null
}
function decodeHtml(value) {
if (value === undefined || value === null) return ""
return String(value)
.replace(/&#(\d+);/g, (_match, dec) => decodeNumericEntity(Number.parseInt(dec, 10), _match))
.replace(/&#x([0-9a-f]+);/gi, (_match, hex) => decodeNumericEntity(Number.parseInt(hex, 16), _match))
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x27;/gi, "'")
.replace(/&nbsp;/g, " ")
}
function decodeNumericEntity(codePoint, fallback) {
try {
if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 0x10ffff) return fallback
return String.fromCodePoint(codePoint)
} catch {
return fallback
}
}
function stripTags(html) {
return decodeHtml(String(html || "")
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]+>/g, " "))
.replace(/\s+/g, " ")
.trim()
}
function getHtmlAttr(attrs, name) {
const match = String(attrs || "").match(new RegExp(`\\b${name}\\s*=\\s*(["'])([\\s\\S]*?)\\1`, "i"))
return match ? decodeHtml(match[2]) : ""
}
function compactObject(value) {
return Object.fromEntries(Object.entries(value).filter(([, entry]) => {
if (entry === null || entry === undefined || entry === "") return false
if (Array.isArray(entry) && entry.length === 0) return false
return true
}))
}
function parsePositiveInteger(value, { defaultValue, min = 1, max, label }) {
if (value === undefined || value === null || String(value).trim() === "") return defaultValue
const text = String(value).trim()
if (!/^\d+$/.test(text)) throw new Error(`Provide valid ${label}.`)
const parsed = Number.parseInt(text, 10)
if (parsed < min) return min
if (Number.isFinite(max) && parsed > max) return max
return parsed
}
function normalizeCategory(value) {
const token = normalizeToken(value || DEFAULT_CATEGORY)
const key = CATEGORY_ALIAS[token] || CATEGORY_CONFIGS[token]?.key
if (!key) throw new Error(`Unsupported SH category: ${value}`)
return key
}
function normalizeSearchType(value, hasKeyword) {
const token = normalizeToken(value)
if (!token) return hasKeyword ? "0" : null
if (["title", "제목", "0"].includes(token)) return "0"
if (["content", "contents", "본문", "내용", "1"].includes(token)) return "1"
throw new Error("srchTp must be title/content or 제목/내용.")
}
function normalizeStatus(value) {
const token = normalizeToken(value)
if (!token) return null
const status = STATUS_ALIASES[token]
if (!status) throw new Error(`Unsupported SH status: ${value}`)
return status
}
function normalizeSearchOptions(options = {}) {
const keyword = trimOrNull(options.keyword ?? options.q ?? options.query ?? options.srchWord)
if (keyword && keyword.length > 100) throw new Error("srchWord must be 100 characters or fewer.")
const category = normalizeCategory(options.category ?? options.kind ?? options.noticeType)
return {
keyword,
srchTp: normalizeSearchType(options.srchTp ?? options.searchType ?? options.type, Boolean(keyword)),
page: parsePositiveInteger(options.page ?? options.pageNo, { defaultValue: 1, min: 1, max: 1000, label: "page" }),
pageSize: parsePositiveInteger(options.pageSize ?? options.limit, { defaultValue: DEFAULT_PAGE_SIZE, min: 1, max: MAX_PAGE_SIZE, label: "pageSize" }),
category,
status: normalizeStatus(options.status),
timeoutMs: parsePositiveInteger(options.timeoutMs, { defaultValue: DEFAULT_TIMEOUT_MS, min: 1, max: 120000, label: "timeoutMs" }),
fetcher: options.fetcher,
signal: options.signal,
includeHtml: Boolean(options.includeHtml)
}
}
function normalizeDetailOptions(options = {}) {
const seq = trimOrNull(options.seq ?? options.noticeSeq ?? options.id)
if (!seq) throw new Error("seq is required")
if (!/^\d{1,20}$/.test(seq)) throw new Error("seq must be digits only.")
const category = normalizeCategory(options.category ?? options.kind ?? options.noticeType)
return {
seq,
category,
timeoutMs: parsePositiveInteger(options.timeoutMs, { defaultValue: DEFAULT_TIMEOUT_MS, min: 1, max: 120000, label: "timeoutMs" }),
fetcher: options.fetcher,
signal: options.signal,
includeHtml: Boolean(options.includeHtml)
}
}
function buildSearchUrl(options = {}) {
const normalized = options.category && options.page ? options : normalizeSearchOptions(options)
const config = CATEGORY_CONFIGS[normalized.category]
const url = new URL(`${SH_BASE_URL}${config.path}/list.do`)
if (config.multiItmSeqs) url.searchParams.set("multi_itm_seqs", config.multiItmSeqs)
if (config.multiItmSeq) url.searchParams.set("multi_itm_seq", config.multiItmSeq)
url.searchParams.set("page", String(normalized.page || 1))
if (normalized.keyword) url.searchParams.set("srchWord", normalized.keyword)
if (normalized.srchTp) url.searchParams.set("srchTp", normalized.srchTp)
return url
}
function buildDetailUrl(options = {}) {
const normalized = options.category && options.seq ? options : normalizeDetailOptions(options)
const config = CATEGORY_CONFIGS[normalized.category]
const url = new URL(`${SH_BASE_URL}${config.path}/view.do`)
if (config.multiItmSeq) url.searchParams.set("multi_itm_seq", config.multiItmSeq)
url.searchParams.set("seq", normalized.seq)
return url
}
function extractTotalCount(html) {
const match = String(html || "").match(/총\s*<strong[^>]*>\s*([0-9,]+)\s*<\/strong>\s*건/i) || stripTags(html).match(/총\s*([0-9,]+)\s*건/)
return match ? Number.parseInt(match[1].replace(/,/g, ""), 10) : null
}
function classifyNoticeStatus(title) {
const text = cleanText(title)
if (/당첨|발표/.test(text)) return "announced"
if (/마감|계약결과|결과|완료|종료/.test(text)) return "closed"
if (/모집공고|입주자\s*모집|신청|접수|공고/.test(text)) return "open"
return "unknown"
}
function statusMatches(itemStatus, requestedStatus) {
if (!requestedStatus) return true
if (requestedStatus === "closed") return itemStatus === "closed"
if (requestedStatus === "announced") return itemStatus === "announced"
return itemStatus === requestedStatus
}
function parseListRows(html, options = {}) {
const normalized = options.category ? options : normalizeSearchOptions(options)
const config = CATEGORY_CONFIGS[normalized.category]
const listAreaMatch = String(html || "").match(/<div\b[^>]*id=["']listTb["'][^>]*>[\s\S]*?<tbody[^>]*>([\s\S]*?)<\/tbody>[\s\S]*?<\/div>/i)
const tbodyMatch = listAreaMatch || String(html || "").match(/<tbody[^>]*>([\s\S]*?)<\/tbody>/i)
const tbody = tbodyMatch ? tbodyMatch[1] : String(html || "")
const rows = []
let rowMatch
const rowRegex = /<tr\b[^>]*>([\s\S]*?)<\/tr>/gi
while ((rowMatch = rowRegex.exec(tbody))) {
const row = rowMatch[1]
const seqMatch = row.match(/getDetailView\(\s*['"]?(\d+)['"]?\s*\)/i)
if (!seqMatch) continue
const cells = [...row.matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/gi)].map((match) => match[1])
if (cells.length < 5) continue
const titleAnchor = cells[1].match(/<a\b[^>]*>([\s\S]*?)<\/a>/i)
const rawTitle = (titleAnchor ? titleAnchor[1] : cells[1]).replace(/<span\b[^>]*class=["'][^"']*icoNew[^"']*["'][^>]*>[\s\S]*?<\/span>/gi, " ")
const title = trimOrNull(stripTags(rawTitle).replace(/^NEW\s*/i, ""))
const seq = seqMatch[1]
const status = classifyNoticeStatus(title)
const item = {
seq,
number: trimOrNull(stripTags(cells[0])),
title,
department: trimOrNull(stripTags(cells[2])),
registered_date: trimOrNull(stripTags(cells[3])),
views: parseNumberOrNull(stripTags(cells[4])),
is_new: /icoNew|>\s*NEW\s*</i.test(cells[1]),
category: config.key,
category_name: config.name,
status,
status_basis: "title_text_classifier",
detail_url: buildDetailUrl({ seq, category: config.key }).toString()
}
if (statusMatches(item.status, normalized.status)) rows.push(compactObject(item))
}
return rows
}
function parseNumberOrNull(value) {
const text = cleanText(value)
return /^[0-9,]+$/.test(text) ? Number.parseInt(text.replace(/,/g, ""), 10) : null
}
function parseListHtml(html, options = {}) {
const normalized = options.category ? options : normalizeSearchOptions(options)
const items = parseListRows(html, normalized).slice(0, normalized.pageSize)
const result = {
query: {
keyword: normalized.keyword || null,
srch_tp: normalized.srchTp || null,
category: normalized.category,
category_name: CATEGORY_CONFIGS[normalized.category].name,
status: normalized.status || null
},
summary: {
page: normalized.page,
page_size: normalized.pageSize,
returned_count: items.length,
total_count: extractTotalCount(html)
},
source: {
name: "sh-public-html",
url: buildSearchUrl(normalized).toString(),
proxy: false
},
warnings: [],
items
}
if (normalized.status) {
result.warnings.push("SH public board has no first-class status field; status filtering uses a conservative title-text classifier.")
}
if (normalized.includeHtml) result.html = html
return result
}
function parseAttachmentDownList(html) {
const match = String(html || "").match(/downList["\']?\s*[:=]\s*(\[[\s\S]*?\])\s*[;,}]/)
if (!match) return []
try {
const parsed = JSON.parse(match[1])
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function isAttachmentIconLabel(value) {
const text = trimOrNull(value)
return !text || /^\.(?:pdf|hwp|hwpx|docx?|xlsx?|pptx?|txt|zip|jpg|jpeg|png|gif|mp[34]|etc)$/i.test(text)
}
function parseAttachments(html) {
const downList = parseAttachmentDownList(html)
const byFileSeq = new Map(downList.map((file) => [String(file.fileSeq || ""), file]))
const attachments = []
const source = String(html || "").replace(/<!--[\s\S]*?-->/g, " ")
const rowRegex = /<tr\b[^>]*>[\s\S]*?<th\b[^>]*>\s*첨부(?:파일)?\s*<\/th>[\s\S]*?<td\b[^>]*>([\s\S]*?)<\/td>[\s\S]*?<\/tr>/gi
let match
while ((match = rowRegex.exec(source))) {
const cell = match[1]
const anchors = [...cell.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi)].map((anchorMatch) => {
const attrs = anchorMatch[1]
return {
className: getHtmlAttr(attrs, "class"),
href: getHtmlAttr(attrs, "href"),
onclick: getHtmlAttr(attrs, "onclick"),
text: trimOrNull(stripTags(anchorMatch[2]))
}
})
const previewUrls = anchors
.map((anchor) => anchor.href)
.filter((href) => /htmlConverter\.do/i.test(href))
.map((href) => new URL(href, SH_BASE_URL).toString())
const fileAnchors = anchors.filter((anchor) => /\bbtnAttach\b/i.test(anchor.className) && /existFile\(\s*['"]?\d+['"]?\s*\)/i.test(anchor.onclick) && !isAttachmentIconLabel(anchor.text))
fileAnchors.forEach((anchor, index) => {
const previewUrl = previewUrls[index] || null
const fileSeq = previewUrl && new URL(previewUrl).searchParams.get("file_seq")
const meta = byFileSeq.get(String(fileSeq || "")) || {}
attachments.push(compactObject({
filename: cleanText(meta.oriFileNm || anchor.text),
file_seq: fileSeq || (meta.fileSeq ? String(meta.fileSeq) : null),
file_size: parseNumberOrNull(meta.fileSize),
file_type: trimOrNull(meta.fileTp),
preview_url: previewUrl
}))
})
}
return attachments
}
function extractDepartment(html) {
const personInfoMatch = String(html || "").match(/<ul\b[^>]*class=["'][^"']*personInfo[^"']*["'][^>]*>([\s\S]*?)<\/ul>/i)
if (!personInfoMatch) return null
const departmentMatch = personInfoMatch[1].match(/담당부서\s*<\/span>\s*:\s*([^<]+)/i) || stripTags(personInfoMatch[1]).match(/담당부서\s*:\s*([^:]+?)(?:담당자|연락처|$)/)
return departmentMatch ? trimOrNull(departmentMatch[1]) : null
}
function parseDetailHtml(html, options = {}) {
const normalized = options.seq ? options : normalizeDetailOptions(options)
const config = CATEGORY_CONFIGS[normalized.category]
const titleMatch = String(html || "").match(/<div\b[^>]*class=["'][^"']*detailTable[^"']*firgs0401Table[^"']*["'][^>]*>[\s\S]*?<caption>([\s\S]*?)<\/caption>/i) ||
String(html || "").match(/<thead>[\s\S]*?<th\b[^>]*colspan=["']2["'][^>]*>([\s\S]*?)<\/th>/i)
const registeredMatch = String(html || "").match(/<strong>\s*등록일\s*:\s*<\/strong>\s*([0-9]{4}[-.][0-9]{2}[-.][0-9]{2})/i)
const viewsMatch = String(html || "").match(/<strong>\s*조회수\s*:\s*<\/strong>\s*([0-9,]+)/i)
const contentMatch = String(html || "").match(/<td\b[^>]*class=["']cont["'][^>]*>([\s\S]*?)<\/td>/i)
const title = trimOrNull(stripTags(titleMatch ? titleMatch[1] : ""))
const attachments = parseAttachments(html)
const detail = compactObject({
seq: normalized.seq,
title,
registered_date: registeredMatch ? registeredMatch[1].replace(/\./g, "-") : null,
views: viewsMatch ? Number.parseInt(viewsMatch[1].replace(/,/g, ""), 10) : null,
department: extractDepartment(html),
category: config.key,
category_name: config.name,
status: classifyNoticeStatus(title),
status_basis: "title_text_classifier",
content_text: trimOrNull(stripTags(contentMatch ? contentMatch[1] : "")),
detail_url: buildDetailUrl(normalized).toString()
})
detail.attachments = attachments
if (normalized.includeHtml) detail.html = html
return detail
}
function createTimeoutSignal(timeoutMs) {
if (typeof AbortSignal === "undefined" || typeof AbortSignal.timeout !== "function") return null
const n = Number(timeoutMs)
return Number.isFinite(n) && n > 0 ? AbortSignal.timeout(n) : null
}
async function fetchText(url, options = {}) {
const fetcher = options.fetcher || global.fetch
if (!fetcher) throw new Error("fetch is required")
const signal = options.signal || createTimeoutSignal(options.timeoutMs || DEFAULT_TIMEOUT_MS)
let response
try {
response = await fetcher(url.toString(), {
headers: {
"user-agent": "Mozilla/5.0 (compatible; k-skill/sh-notice-search)",
accept: "text/html,application/xhtml+xml"
},
signal
})
} catch (error) {
throw new Error(`SH upstream request failed: ${error.message}`)
}
const text = await response.text()
if (!response.ok) {
throw new Error(`SH upstream responded with HTTP ${response.status}: ${text.slice(0, 200)}`)
}
return text
}
async function searchNotices(options = {}) {
const normalized = normalizeSearchOptions(options)
const html = await fetchText(buildSearchUrl(normalized), normalized)
return parseListHtml(html, normalized)
}
async function getNoticeDetail(options = {}) {
const normalized = normalizeDetailOptions(options)
const html = await fetchText(buildDetailUrl(normalized), normalized)
return {
notice: parseDetailHtml(html, normalized),
query: {
seq: normalized.seq,
category: normalized.category,
category_name: CATEGORY_CONFIGS[normalized.category].name
},
source: {
name: "sh-public-html",
url: buildDetailUrl(normalized).toString(),
proxy: false
}
}
}
module.exports = {
SH_BASE_URL,
DEFAULT_CATEGORY,
CATEGORY_CONFIGS,
STATUS_ALIASES,
cleanText,
stripTags,
normalizeCategory,
normalizeSearchOptions,
normalizeDetailOptions,
buildSearchUrl,
buildDetailUrl,
extractTotalCount,
classifyNoticeStatus,
parseListRows,
parseListHtml,
parseAttachmentDownList,
parseAttachments,
parseDetailHtml,
createTimeoutSignal,
searchNotices,
getNoticeDetail
}

View file

@ -0,0 +1,179 @@
const test = require("node:test")
const assert = require("node:assert/strict")
const { spawnSync } = require("node:child_process")
const {
CATEGORY_CONFIGS,
buildSearchUrl,
buildDetailUrl,
normalizeSearchOptions,
normalizeDetailOptions,
parseListHtml,
parseDetailHtml,
searchNotices,
getNoticeDetail
} = require("../src/index")
const LIST_HTML = `<!doctype html><html><body>
<form name="mainform" action="./list.do" method="post">
<input type="hidden" name="page" id="page" value="1" />
<input type="hidden" name="multi_itm_seq" value="2" />
<select name="srchTp" id="s_keyword"><option value="0" selected>제목</option><option value="1"></option></select>
<input type="text" value="행복주택" name="srchWord" />
</form>
<div class="topTxt"><p> <strong class="cBrown bold">95</strong> [1/10]</p></div>
<div id="listTb" class="listTable colRm"><table>
<caption>주택임대 공고 공지 목록</caption>
<thead><tr><th>번호</th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
<tr><td>95</td><td class="txtL"><a href="#" onclick="javascript:getDetailView('304371');return false;"><span class="icoNew">NEW</span> </a></td><td></td><td class="num">2026-05-14</td><td class="num">872</td></tr>
<tr><td>94</td><td class="txtL"><a href="#" onclick="javascript:getDetailView('304346');return false;"> </a></td><td></td><td class="num">2026-05-14</td><td class="num">1,210</td></tr>
</tbody></table></div>
</body></html>`
const DETAIL_HTML = `<!doctype html><html><body>
<script>
const initParam = { downList: [{"brdId":"GS0401","seq":"304371","fileSeq":"1","fileSize":"131614","oriFileNm":"2025년 2차 행복주택 예비3차 계약결과.pdf","fileTp":"A"},{"brdId":"GS0401","seq":"304371","fileSeq":"2","fileSize":"2816","oriFileNm":"추가 안내문.hwp","fileTp":"A"}] };
</script>
<div class="detailTable gs0401Table firgs0401Table"><table>
<caption>행복주택 예비자 계약결과 알림</caption>
<thead><tr><th scope="col" colspan="2">행복주택 예비자 계약결과 알림</th></tr></thead>
<tbody>
<tr><td colspan="2"><ul><li><strong>등록일 : </strong>2026-05-14</li><li><strong> : </strong>875</li></ul></td></tr>
<tr><th scope="row">첨부</th><td>
<!-- icon template should not be parsed as a real attachment
<a href="#" class="btnAttach v1">.pdf</a><a href="#" class="btnAttach v2">.hwp</a>
-->
<a href="#" class="btnAttach v1" onclick="existFile('0'); return false;">2025 2 행복주택 예비3차 계약결과.pdf</a>
<a href="/app/com/util/htmlConverter.do?brd_id=GS0401&amp;seq=304371&amp;data_tp=A&amp;file_seq=1" class="btn btnWhite h32 icoView">미리보기</a>
<a href="#" class="btnAttach v2" onclick="existFile('1'); return false;">추가 안내문.hwp</a>
<a href="/app/com/util/htmlConverter.do?brd_id=GS0401&amp;seq=304371&amp;data_tp=A&amp;file_seq=2" class="btn btnWhite h32 icoView">미리보기</a>
</td></tr>
<tr><td colspan="2" class="cont"><p>행복주택 예비자 계약결과알림</p><p>2025 2 .</p></td></tr>
</tbody></table></div>
<form name="mainform"><input type="hidden" name="srchWord" id="srchWord" value="행복주택" /></form>
<ul class="personInfo"><li><span>담당부서</span> : </li></ul>
</body></html>`
test("normalizeSearchOptions defaults keyword searches to SH title scope", () => {
const options = normalizeSearchOptions({ keyword: "행복주택", limit: 50, page: "2" })
assert.equal(options.keyword, "행복주택")
assert.equal(options.srchTp, "0")
assert.equal(options.page, 2)
assert.equal(options.pageSize, 10)
assert.equal(options.category, "rent")
})
test("normalizeSearchOptions maps content scope, category aliases, and status", () => {
const options = normalizeSearchOptions({ q: "매입임대", searchType: "내용", category: "주거복지", status: "진행" })
assert.equal(options.srchTp, "1")
assert.equal(options.category, "purchase")
assert.equal(options.status, "open")
})
test("normalizeSearchOptions rejects invalid bounded inputs", () => {
assert.throws(() => normalizeSearchOptions({ q: "x".repeat(101) }), /100 characters/)
assert.throws(() => normalizeSearchOptions({ page: "abc" }), /valid page/)
assert.throws(() => normalizeSearchOptions({ category: "unknown" }), /Unsupported SH category/)
assert.throws(() => normalizeSearchOptions({ status: "maybe" }), /Unsupported SH status/)
})
test("buildSearchUrl targets the public SH list page directly and sets srchTp", () => {
const url = buildSearchUrl(normalizeSearchOptions({ q: "행복주택", category: "rent" }))
assert.equal(url.hostname, "www.i-sh.co.kr")
assert.equal(url.pathname, CATEGORY_CONFIGS.rent.path + "/list.do")
assert.equal(url.searchParams.get("srchWord"), "행복주택")
assert.equal(url.searchParams.get("srchTp"), "0")
assert.equal(url.searchParams.get("multi_itm_seq"), "2")
})
test("buildSearchUrl uses official category-specific board paths", () => {
const sale = buildSearchUrl(normalizeSearchOptions({ category: "분양" }))
const welfare = buildSearchUrl(normalizeSearchOptions({ category: "welfare" }))
assert.equal(sale.pathname, CATEGORY_CONFIGS.sale.path + "/list.do")
assert.equal(sale.searchParams.get("multi_itm_seq"), "1")
assert.equal(welfare.pathname, CATEGORY_CONFIGS.purchase.path + "/list.do")
assert.equal(welfare.searchParams.get("multi_itm_seq"), "512")
})
test("parseListHtml returns rows, total count, category, and detail URLs", () => {
const result = parseListHtml(LIST_HTML, normalizeSearchOptions({ q: "행복주택", category: "rent" }))
assert.equal(result.summary.total_count, 95)
assert.equal(result.summary.returned_count, 2)
assert.equal(result.items[0].seq, "304371")
assert.equal(result.items[0].title, "행복주택 예비자 계약결과 알림")
assert.equal(result.items[0].views, 872)
assert.equal(result.items[0].is_new, true)
assert.equal(result.items[0].category, "rent")
assert.equal(result.items[0].category_name, "주택임대")
assert.match(result.items[0].detail_url, /www\.i-sh\.co\.kr/)
})
test("parseListHtml applies conservative status filtering after parsing", () => {
const closed = parseListHtml(LIST_HTML, normalizeSearchOptions({ status: "closed" }))
const open = parseListHtml(LIST_HTML, normalizeSearchOptions({ status: "open" }))
assert.equal(closed.items.length, 1)
assert.match(closed.items[0].title, /계약결과/)
assert.equal(open.items.length, 0)
})
test("parseDetailHtml extracts real attachments by existFile onclick, not icon templates", () => {
const detail = parseDetailHtml(DETAIL_HTML, normalizeDetailOptions({ seq: "304371", category: "rent" }))
assert.equal(detail.seq, "304371")
assert.equal(detail.title, "행복주택 예비자 계약결과 알림")
assert.equal(detail.registered_date, "2026-05-14")
assert.equal(detail.views, 875)
assert.equal(detail.department, "공공주택공급부")
assert.match(detail.content_text, /입주자모집 계약 결과/)
assert.equal(detail.attachments.length, 2)
assert.deepEqual(detail.attachments[0], {
filename: "2025년 2차 행복주택 예비3차 계약결과.pdf",
file_seq: "1",
file_size: 131614,
file_type: "A",
preview_url: "https://www.i-sh.co.kr/app/com/util/htmlConverter.do?brd_id=GS0401&seq=304371&data_tp=A&file_seq=1"
})
assert.equal(Object.hasOwn(detail.attachments[0], "download_url"), false)
})
test("searchNotices and getNoticeDetail fetch official SH HTML with caller-injected fetch", async () => {
const calls = []
const fetcher = async (url, options) => {
calls.push({ url: String(url), options })
return { ok: true, status: 200, statusText: "OK", text: async () => String(url).includes("view.do") ? DETAIL_HTML : LIST_HTML }
}
const list = await searchNotices({ keyword: "행복주택", fetcher })
const detail = await getNoticeDetail({ seq: list.items[0].seq, fetcher })
assert.equal(calls[0].url, buildSearchUrl(normalizeSearchOptions({ keyword: "행복주택" })).toString())
assert.match(calls[0].options.headers["user-agent"], /k-skill\/sh-notice-search/)
assert.equal(list.items.length, 2)
assert.equal(detail.notice.attachments.length, 2)
})
test("CLI parses options and prints help", () => {
const cli = require("../src/cli")
assert.deepEqual(cli.parseArgs(["행복주택", "--category", "임대", "--status", "마감", "--page", "5", "--limit", "20"]), {
keyword: "행복주택",
category: "임대",
status: "마감",
page: "5",
limit: "20"
})
const help = spawnSync(process.execPath, ["src/cli.js", "--help"], {
cwd: __dirname + "/..",
encoding: "utf8"
})
assert.equal(help.status, 0)
assert.match(help.stdout, /Usage: sh-notice-search/)
})

160
sh-notice-search/SKILL.md Normal file
View file

@ -0,0 +1,160 @@
---
name: sh-notice-search
description: 서울주택도시개발공사(SH) 공개 공고/공지 게시판에서 청약·주택 공고 목록, 상세 본문, 첨부 미리보기 메타데이터를 직접 조회한다.
license: MIT
metadata:
category: housing
locale: ko-KR
phase: v1
---
# SH Notice Search
## What this skill does
서울주택도시개발공사(SH, `www.i-sh.co.kr`)의 **공고 및 공지** 공개 HTML 게시판을 직접 읽어 청약·주택 공고 목록과 상세 본문, 첨부파일 메타데이터를 JSON으로 정리한다.
- 키워드로 SH 공고/공지 목록을 검색한다.
- 공식 게시판 분류(주택임대, 주택분양, 주택매입/주거복지, 토지, 상가/공장 등)를 선택한다.
- 상세 페이지에서 본문, 담당부서, 등록일, 조회수, 실제 첨부파일명을 추출한다.
- 첨부는 아이콘 템플릿이 아니라 `existFile('N')` onclick이 달린 실제 첨부 앵커와 `downList` 메타데이터를 기준으로 추출한다.
청약 신청, 서류 제출, 로그인 필요한 마이페이지 조회, 결제, 알림 발송은 하지 않는다.
## When to use
- "SH 행복주택 공고 찾아줘"
- "서울주택도시개발공사 매입임대 공고 보여줘"
- "SH 공고 seq 304371 상세와 첨부파일 알려줘"
- "SH 분양 공고 최신 목록 조회"
## Prerequisites
- 인터넷 연결
- Node.js 18+
- 이 저장소의 `sh-notice-search` npm package 또는 동일 로직
## Public access path discovered
### Primary source: official SH public HTML board
- default rent list: `https://www.i-sh.co.kr/app/lay2/program/S1T294C297/www/brd/m_247/list.do?multi_itm_seq=2`
- default rent detail: `https://www.i-sh.co.kr/app/lay2/program/S1T294C297/www/brd/m_247/view.do?multi_itm_seq=2&seq=<seq>`
- title keyword search: add `srchWord=<keyword>&srchTp=0`
- content keyword search: add `srchWord=<keyword>&srchTp=1`
- fixed board page size: 10 rows per page; use `page` for pagination.
Discovery result: direct unauthenticated fetches from `www.i-sh.co.kr` return list/detail HTML. A live smoke on 2026-05-15 showed `srchWord=행복주택` without `srchTp` returned the full rent board count, while `srchTp=0` narrowed the result set. Therefore the client always sends `srchTp` when a keyword is present.
No `k-skill-proxy` route is used because this upstream is public and does not require an API key.
## Supported category aliases
| Input aliases | Official tab |
| --- | --- |
| `rent`, `임대`, `주택임대` | 주택임대 (`multi_itm_seq=2`) |
| `sale`, `분양`, `주택분양` | 주택분양 (`multi_itm_seq=1`) |
| `purchase`, `매입`, `매입임대`, `welfare`, `주거복지` | 주택매입 (`multi_itm_seq=512`) |
| `land`, `토지` | 토지 |
| `commercial`, `상가`, `공장` | 상가/공장 |
| `compensation`, `보상`, `이주` | 보상/이주 |
| `design`, `현상설계` | 현상설계 |
| `etc`, `기타` | 기타 |
| `all`, `전체` | 전체 |
`주거복지`는 SH 공고 및 공지의 공개 탭명이 아니라 사용자 친화 alias이며, 현재는 SH의 공개 `주택매입` 탭으로 매핑한다. 답변할 때는 이 매핑을 밝힌다.
## Workflow
### 1. Search notices
```js
const { searchNotices } = require("sh-notice-search")
const result = await searchNotices({
keyword: "행복주택",
category: "임대",
page: 1,
limit: 5
})
console.log(result.items)
```
CLI:
```bash
node packages/sh-notice-search/src/cli.js 행복주택 --category 임대 --limit 5
node packages/sh-notice-search/src/cli.js 매입임대 --category 주거복지 --status 진행
```
Returned list fields include:
- `seq`
- `title`
- `department`
- `registered_date`
- `views`
- `is_new`
- `category`, `category_name`
- `status`, `status_basis`
- `detail_url`
### 2. Fetch detail
```js
const { getNoticeDetail } = require("sh-notice-search")
const detail = await getNoticeDetail({ seq: "304371", category: "임대" })
console.log(detail.notice.content_text)
console.log(detail.notice.attachments)
```
CLI:
```bash
node packages/sh-notice-search/src/cli.js --seq 304371 --category 임대
```
Attachment fields:
- `filename`
- `file_seq`
- `file_size`
- `file_type`
- `preview_url` (official SH preview/converter URL)
Direct download URLs are intentionally not returned. Hand off `detail_url` or `preview_url` to the user's browser.
### 3. Interpret status conservatively
The SH public board list does not expose a first-class status field like `접수중`/`마감`. The package can filter by `status`, but it is a title-text classifier:
- `open`/`진행`: titles with 모집공고, 입주자 모집, 신청, 접수, 공고
- `closed`/`마감`: titles with 마감, 계약결과, 결과, 완료, 종료
- `announced`/`당첨자`: titles with 당첨, 발표
When answering, disclose that status is inferred from the title unless the detailed 공고문 body states exact dates.
## Done when
- Official SH list/detail URLs were queried directly from the user machine.
- Keyword searches include `srchTp` so `srchWord` is not ignored.
- Pagination uses `page` and recognizes the fixed 10-row board page size.
- Attachments are extracted from actual `existFile()` anchors/downList metadata, not extension icon templates.
- Public source URLs are shown, and login/application automation is avoided.
## Failure modes
- SH can change board paths, table markup, JavaScript functions, or `downList` structure; parsing may become partial or fail.
- IP-rate-limit, NetFunnel throttling, maintenance pages, or temporary 4xx/5xx responses can block live fetches. Do not bypass CAPTCHA/login/queue protections.
- `srchWord` without `srchTp` is known to be ignored by the SH board; always send `srchTp=0` for title or `srchTp=1` for content.
- `pageSize` larger than 10 does not make SH return more rows. Use `page` for additional results.
- Attachment preview URLs may require browser handoff and can be governed by SH's current direct-link/download policy.
- Status is inferred from title text because the public list lacks an explicit status column.
## Notes
- Read-only lookup only.
- No proxy, no API key, no secrets.
- Do not automate 청약 신청, 로그인, 서류 제출, payment, or 마이페이지 flows.