mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
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:
parent
5a6dcedb99
commit
c83e194a84
11 changed files with 1115 additions and 1 deletions
5
.changeset/sh-notice-search.md
Normal file
5
.changeset/sh-notice-search.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"sh-notice-search": minor
|
||||
---
|
||||
|
||||
Add a policy-compliant SH public notice search skill and direct HTML lookup client.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
96
docs/features/sh-notice-search.md
Normal file
96
docs/features/sh-notice-search.md
Normal 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
14
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
41
packages/sh-notice-search/README.md
Normal file
41
packages/sh-notice-search/README.md
Normal 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.
|
||||
36
packages/sh-notice-search/package.json
Normal file
36
packages/sh-notice-search/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
72
packages/sh-notice-search/src/cli.js
Executable file
72
packages/sh-notice-search/src/cli.js
Executable 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 }
|
||||
509
packages/sh-notice-search/src/index.js
Normal file
509
packages/sh-notice-search/src/index.js
Normal 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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/ /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
|
||||
}
|
||||
179
packages/sh-notice-search/test/index.test.js
Normal file
179
packages/sh-notice-search/test/index.test.js
Normal 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&seq=304371&data_tp=A&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&seq=304371&data_tp=A&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
160
sh-notice-search/SKILL.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue