Compare commits

...

4 commits

Author SHA1 Message Date
Jeffrey (Dongkyu) Kim
ec4875bd3a Honor explicit public crawl budgets
Keep broad triathlon searches bounded by applying one detail budget across selected year lists and exposing the same budget control in the CLI.

Constraint: PR #222 review requested shared triathlon crawl budget and CLI access to maxDetailsPerSource.

Rejected: Per-year triathlon budget counters | they can exceed the documented per-source crawl cap on multi-year ranges.

Confidence: high

Scope-risk: narrow

Directive: Keep public-source crawl caps source-scoped and documented when adding more list partitions.

Tested: npm test --workspace korean-marathon-schedule; npm run lint --workspace korean-marathon-schedule; live CLI 고령 smoke; CLI help grep; npm run ci; git diff --check; architect verification CLEAR

Not-tested: Live multi-year low-budget triathlon crawl against upstream beyond mocked regression.
2026-05-09 23:23:22 +09:00
Jeffrey (Dongkyu) Kim
c28e0a0839 Bound marathon schedule crawling to trusted sources
Fix review-round false negatives by continuing beyond the old pre-filter windows while adding an explicit per-source detail budget and warnings for partial crawls. Keep race detail traversal constrained to documented hosts and filter triathlon non-race rows before fetching details.\n\nConstraint: Review round required TDD, live verification, full CI, and preserving the public no-proxy source boundary.\nRejected: Exhaustive unbounded detail traversal | it maximizes recall but can over-crawl public list pages.\nConfidence: high\nScope-risk: narrow\nDirective: Keep future crawling changes host-allowlisted, budgeted, and warning-producing when partial.\nTested: npm test --workspace korean-marathon-schedule; npm run lint --workspace korean-marathon-schedule; node packages/korean-marathon-schedule/src/cli.js 고령 --from 2026-01-01 --to 2026-12-31 --include-triathlon --limit 5; node packages/korean-marathon-schedule/src/cli.js 용인 --from 2026-05-01 --to 2026-06-30 --limit 3; npm run ci; architect verification CLEAR.\nNot-tested: Live off-origin or malformed upstream HTML beyond mocked regressions.
2026-05-09 23:10:49 +09:00
Jeffrey (Dongkyu) Kim
7c3efe41ed Keep marathon locations authoritative
Fix the reviewed GoRunning region inference bug by ranking event location fields ahead of full-page text, and remove the unrelated public SH notice proxy/skill surface so the PR remains inside the approved marathon scope and proxy policy.

Constraint: PR #222 review required TDD, full verification, and removal of public unauthenticated SH proxy routes before merge-readiness.
Rejected: Keeping /v1/sh-notice as a proxy route | violates the repository free-API proxy inclusion rule for public unauthenticated HTML.
Confidence: high
Scope-risk: narrow
Directive: Do not reintroduce public unauthenticated SH scraping through k-skill-proxy without an explicit documented policy exception.
Tested: npm test --workspace korean-marathon-schedule; node packages/korean-marathon-schedule/src/cli.js 용인 --from 2026-05-01 --to 2026-06-30 --limit 3; node packages/korean-marathon-schedule/src/cli.js 고령 --from 2026-01-01 --to 2026-12-31 --include-triathlon --limit 5; npm run lint --workspace k-skill-proxy; npm test --workspace k-skill-proxy; grep -RIn 'sh-notice\|i-sh.co.kr' README.md docs packages package.json package-lock.json .changeset; npm run ci; git diff --check; architect verification CLEAR.
Not-tested: None.
2026-05-09 22:46:17 +09:00
Jeffrey (Dongkyu) Kim
341a2b00d3 Add public marathon schedule lookup
Implement a read-only Korean marathon schedule skill so agents can report event dates, venues, registration deadlines, and categories from public race pages, with best-effort triathlon coverage.

Constraint: Issue #211 requires 장소, 신청 마감일, 종목, and possible triathlon inclusion without interactive clarification.

Constraint: Public unauthenticated GoRunning and triathlon.or.kr surfaces do not require k-skill-proxy.

Rejected: Proxy route | upstream pages are public and need no API key, so proxying would violate the free API proxy inclusion rule.

Confidence: high

Scope-risk: moderate

Directive: Keep source parsing fail-soft with explicit warnings when one public source changes or is temporarily unavailable.

Tested: npm test --workspace korean-marathon-schedule; live CLI smoke for 고령 2026 triathlon category; npm run ci; architect verification approved.

Not-tested: Real-time coverage of every future race page variant across both upstream sites.

Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-05-09 22:29:59 +09:00
19 changed files with 1258 additions and 852 deletions

View file

@ -0,0 +1,5 @@
---
"korean-marathon-schedule": minor
---
Add a Korean marathon and triathlon schedule lookup skill backed by public event pages.

View file

@ -39,7 +39,6 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 한국 부동산 실거래가 조회 | `real-estate-search` | 아파트/오피스텔/빌라/단독주택 실거래가·전월세·지역코드 조회 | 불필요 | [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md) |
| 개별공시지가 조회 | `gongsijiga-search` | realtyprice.kr 공개 API에서 지번 단위 개별공시지가(원/㎡) 다년도 추이·전년 대비 변동률 조회 | 불필요 | [개별공시지가 조회 가이드](docs/features/gongsijiga-search.md) |
| LH 청약 공고문 조회 | `lh-notice-search` | 한국토지주택공사(LH) 임대/분양/주거복지(신혼희망타운)/토지/상가 공고를 지역·상태·공고유형·키워드로 조회하고 마감 여부를 KST 기준으로 표시 | 불필요 | [LH 청약 공고문 조회 가이드](docs/features/lh-notice-search.md) |
| SH 청약·주택 공고문 조회 | `sh-notice-search` | 서울주택도시개발공사(SH) 공고/공지 게시판을 키워드로 검색하고 상세 본문·첨부 미리보기 링크를 확인 | 불필요 | [SH 청약·주택 공고문 조회 가이드](docs/features/sh-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) |
| 장학금 검색 및 조회 | `korean-scholarship-search` | 한국장학재단·전국 대학교·재단·기업 장학 공고를 검색해 금액·자격·지원구간·링크를 정리하고 KST 기준 현재 날짜 마감 상태와 조건별 필터링까지 제공 | 불필요 | [장학금 검색 및 조회 가이드](docs/features/korean-scholarship-search.md) |
@ -56,6 +55,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 근처 가장 싼 주유소 찾기 | `cheap-gas-nearby` | 현재 위치 기준 근처 최저가 주유소 조회 | 불필요 | [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md) |
| 근처 공중화장실 찾기 | `public-restroom-nearby` | 현재 위치 기준 근처 공중화장실/개방화장실 조회 | 불필요 | [근처 공중화장실 찾기 가이드](docs/features/public-restroom-nearby.md) |
| 근처 공영주차장 찾기 | `parking-lot-search` | 현재 위치 기준 근처 공영주차장 위치·요금·운영시간 조회 | 불필요 | [근처 공영주차장 찾기 가이드](docs/features/parking-lot-search.md) |
| 한국 마라톤 일정 조회 | `korean-marathon-schedule` | 고러닝 공개 페이지와 대한철인3종협회 일정에서 마라톤·철인3종 대회 일정, 장소, 신청 마감일, 종목 조회 | 불필요 | [한국 마라톤 일정 조회 가이드](docs/features/korean-marathon-schedule.md) |
| KBO 경기 결과 조회 | `kbo-results` | 날짜별 KBO 경기 일정, 결과, 팀별 필터링 | 불필요 | [KBO 결과 가이드](docs/features/kbo-results.md) |
| KBL 경기 결과 조회 | `kbl-results` | 날짜별 KBL 경기 일정, 결과, 팀별 필터링, 현재 순위 확인 | 불필요 | [KBL 경기 결과 가이드](docs/features/kbl-results.md) |
| K리그 경기 결과 조회 | `kleague-results` | 날짜별 K리그1/K리그2 경기 결과, 팀별 필터링, 현재 순위 확인 | 불필요 | [K리그 결과 가이드](docs/features/kleague-results.md) |
@ -134,7 +134,6 @@ 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)
@ -150,6 +149,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md)
- [근처 공중화장실 찾기 가이드](docs/features/public-restroom-nearby.md)
- [근처 공영주차장 찾기 가이드](docs/features/parking-lot-search.md)
- [한국 마라톤 일정 조회 가이드](docs/features/korean-marathon-schedule.md)
- [KBO 경기 결과 조회](docs/features/kbo-results.md)
- [KBL 경기 결과 가이드](docs/features/kbl-results.md)
- [K리그 경기 결과 조회](docs/features/kleague-results.md)

View file

@ -0,0 +1,66 @@
# 한국 마라톤 일정 조회 가이드
`korean-marathon-schedule` 스킬은 공개 웹 표면을 읽어 한국 마라톤/러닝 대회 일정을 조회하고, 요청 시 철인3종 대회도 함께 확인합니다.
## 제공 정보
각 결과는 가능한 범위에서 아래 정보를 반환합니다.
- 대회명
- 개최일
- 지역과 장소
- 신청 마감일 및 접수 기간
- 종목/코스
- 주최자
- 공식 웹사이트 또는 공개 상세 링크
## 공개 접근 경로
| 구분 | 공개 표면 | 사용 정보 | 인증 |
| --- | --- | --- | --- |
| 마라톤/러닝 | `https://gorunning.kr/races/``/races/<id>/<slug>/` 상세 페이지 | 일정, 장소, 접수 기간, 종목, 주최자, 웹사이트 | 불필요 |
| 철인3종 | `https://triathlon.or.kr/events/tour/?sYear=<YYYY>&vType=list` 및 상세 페이지 | 일정, 장소, 접수 기간, 코스, 주최자 | 불필요 |
두 표면 모두 API 키가 필요 없는 공개 읽기 경로이므로 `k-skill-proxy`를 사용하지 않습니다.
## 사용 예시
```js
const { searchEvents } = require("korean-marathon-schedule")
const result = await searchEvents({
query: "서울",
from: "2026-05-01",
to: "2026-12-31",
includeTriathlon: true,
limit: 10
})
console.log(result.items)
```
CLI:
```bash
node packages/korean-marathon-schedule/src/cli.js 서울 --from 2026-05-01 --to 2026-12-31 --include-triathlon --limit 10
```
## 응답 작성 원칙
```text
- 대회명: 소아암환우돕기 제23회 서울시민마라톤
일정: 2026-05-10
장소: 서울 여의도 한강 물빛광장
신청 마감: 2026-02-28 (접수기간 2026-01-12 ~ 2026-02-28)
종목: Half, 10km, 5km, 3km 걷기
링크: https://gorunning.kr/races/...
```
신청 마감일이 공개 페이지에서 확인되지 않으면 추정하지 말고 `신청 마감일 미확인`으로 표시합니다.
## 실패/주의 사항
- 일정과 접수 상태는 수시로 바뀌므로 조회 시각 기준 참고값으로 안내합니다.
- 공개 HTML 구조가 바뀌면 일부 필드가 비거나 파싱이 실패할 수 있습니다.
- 접수/결제/로그인/CAPTCHA가 필요한 경로는 자동화하지 않습니다.
- 행사별 공식 사이트가 없으면 GoRunning 또는 대한철인3종협회 상세 링크를 대신 제공합니다.

View file

@ -1,93 +0,0 @@
# SH 청약·주택 공고문 조회 가이드
서울주택도시개발공사(SH, `i-sh.co.kr`)가 운영하는 **공고 및 공지** 게시판을 검색·상세 조회한다. SH는 LH 처럼 공공데이터포털에 안정적인 공고 Open API 가 열려 있지 않기 때문에, 프록시 서버가 공식 SH HTML 게시판을 직접 읽고 정규화한다. 본 스킬은 read-only 조회만 다룬다.
## 이 기능으로 할 수 있는 일
- SH 공고/공지 게시판의 최신 목록 조회 (장기전세·행복주택·매입임대·공공원룸 등 SH 가 직접 게시하는 공고)
- 공고 제목 키워드 검색 (예: `행복주택`, `장기전세`, `미리내집`, `당첨자`)
- 본문 키워드 검색 (`srchTp=content`)
- 공고 상세: 본문 텍스트, 등록일, 조회수, 첨부파일명, 미리보기 링크
## 가장 중요한 규칙
기본 경로는 `https://k-skill-proxy.nomadamas.org/v1/sh-notice/...` 이며, 사용자는 별도 인증 키를 준비할 필요가 없다. SH 사이트는 인증 없이 공개되어 있다.
본 스킬은 **SH 게시판 전용**이다. LH(한국토지주택공사) 공고는 `lh-notice-search` 스킬을, GH(경기)·iH(인천) 등 다른 지방 주택공사 공고는 본 스킬에서 다루지 않는다. SH `seq` 는 SH 게시글 번호이며 LH `pan_id` 와 다른 ID 체계다.
## 먼저 필요한 것
- 인터넷 연결
- `curl` 또는 HTTP 호출이 가능한 도구
## 지원 엔드포인트
| Route | 설명 |
| --- | --- |
| `GET /v1/sh-notice/search` | 공고 목록 조회. 모든 파라미터 선택사항. |
| `GET /v1/sh-notice/detail` | 공고 상세 + 본문 + 첨부 미리보기 링크. `seq` 필수. |
### `/v1/sh-notice/search` 파라미터
| 파라미터 | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `q` / `keyword` / `srchWord` | string | (없음) | 검색어. 최대 100자 |
| `srchTp` / `searchType` | string | 키워드가 있으면 `title` | `title`/`제목` 또는 `content`/`내용`. 검색어가 있고 비우면 자동으로 제목 검색이 적용된다 |
| `page` | int | 1 | 페이지 (최대 1000) |
| `pageSize` / `limit` | int | 10 | 페이지당 건수. **SH 게시판이 한 페이지에 최대 10건만 응답하므로 값은 10으로 캡된다.** 더 많은 결과는 `page` 를 증가시켜 조회한다 |
| `multiItmSeq` | digits | 2 | SH 게시판 분류. `2` = 공고 및 공지 |
### `/v1/sh-notice/detail` 파라미터
| 파라미터 | 타입 | 필수 | 설명 |
| --- | --- | --- | --- |
| `seq` / `noticeSeq` / `id` | digits | ✅ | SH 게시글 번호. 목록 응답의 `seq` |
| `multiItmSeq` | digits | (선택) | 기본 `2` |
## 기본 흐름
1. 사용자 요청에서 키워드와 게시판 분류 의도를 추출한다.
2. 키워드만 있으면 `srchTp` 가 자동으로 `title` 로 처리된다는 점을 활용해 `/v1/sh-notice/search` 를 호출한다.
3. 결과 상위 3~5건만 간결히 보여주고, 제목·담당부서·등록일·조회수·공식 상세 링크를 포함한다.
4. 사용자가 더 깊이 알고 싶어하면 `seq``/v1/sh-notice/detail` 을 호출해 본문 요약과 첨부 미리보기 링크를 제시한다.
5. 공식 사이트(`detail_url`)와 미리보기 링크(`preview_url`)는 항상 함께 제시한다.
## 예시
```bash
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
# 행복주택 공고 최근 3건 (제목 검색이 자동 적용됨)
curl -fsS --get "${BASE%/}/v1/sh-notice/search" \
--data-urlencode 'q=행복주택' \
--data-urlencode 'pageSize=3'
# 본문에서 '당첨자' 가 들어간 공고 검색
curl -fsS --get "${BASE%/}/v1/sh-notice/search" \
--data-urlencode 'q=당첨자' \
--data-urlencode 'srchTp=content'
# 특정 공고 상세 보기
curl -fsS --get "${BASE%/}/v1/sh-notice/detail" \
--data-urlencode 'seq=303994'
```
## 응답 정책
- 공식 SH 사이트(`www.i-sh.co.kr`) 정보만 사용한다.
- 마감일이 별도 필드로 제공되지 않는 게시판 구조다. 본문/첨부 공고문에 있는 접수기간은 상세 본문을 읽고 별도 추출·요약해야 한다.
- 첨부 원문 확인에는 `preview_url` (공식 SH 미리보기 변환 URL) 을 우선 사용한다. 직접 다운로드 URL 은 SH 사이트 흐름이 바뀔 수 있어 제공하지 않는다.
## 실패 모드
- `seq` 또는 `multiItmSeq` 가 숫자가 아니면 `400 bad_request`.
- 검색어가 100자를 초과하면 `400 bad_request`.
- SH 사이트가 일시 장애이거나 HTML 구조가 바뀌면 `502 upstream_error` 또는 빈 결과가 내려올 수 있다.
- SH 게시판은 `srchTp` 없이 `srchWord` 만 보내면 키워드를 무시하고 전체 목록을 돌려주는 특성이 있어, 프록시가 자동으로 `srchTp=1` (제목 검색) 으로 폴백한다.
## 사용하지 않는 경우
- LH(한국토지주택공사) 전용 공고 → `lh-notice-search` 사용
- GH(경기)·iH(인천) 등 다른 지방공사 공고
- 청약 신청 자동화/제출, 로그인 필요한 마이페이지 업무
- 개별 자격 심사, 당첨 예측, 가점 계산

View file

@ -92,7 +92,6 @@ npx --yes skills add <owner/repo> \
--skill k-schoollunch-menu \
--skill korean-character-count \
--skill court-auction-notice-search \
--skill sh-notice-search \
--skill donation-place-search \
--skill k-skill-cleaner
```

View file

@ -0,0 +1,121 @@
---
name: korean-marathon-schedule
description: 고러닝과 대한철인3종협회 공개 표면으로 한국 마라톤·철인3종 경기 일정, 장소, 신청 마감일, 종목을 조회한다.
license: MIT
metadata:
category: sports
locale: ko-KR
phase: v1
---
# Korean Marathon Schedule
## What this skill does
한국 마라톤/러닝 대회 일정을 조회하고, 가능한 경우 대한철인3종협회 공개 일정에서 철인3종 대회도 함께 확인한다.
응답에는 최소한 아래 필드를 포함한다.
- 대회명
- 개최일
- 장소/지역
- 신청 마감일 또는 접수 기간
- 종목/코스(예: Half, 10km, 5km, 스탠다드)
- 공식/상세 링크
- 조회 시점 기준 정보라는 주의 문구
## When to use
- "서울 마라톤 일정 찾아줘"
- "10km 대회 접수 마감일 알려줘"
- "가을 마라톤 일정과 장소 정리해줘"
- "철인3종 경기 일정도 가능하면 같이 봐줘"
## Prerequisites
- 인터넷 연결
- Node.js 18+
- 이 저장소의 `korean-marathon-schedule` npm package 또는 동일 로직
## Public access path discovered
### Primary marathon source: GoRunning
- list entry point: `https://gorunning.kr/races/`
- detail pages: same-host `gorunning.kr` links matching `/races/<id>/<slug>/`
- detail fields used: title, event date, region/venue, registration period, registration deadline, status, organizer, website, categories.
- reason selected: public unauthenticated race list/detail pages include the required venue, deadline/registration period, and event categories. It works with direct HTTP requests and does not require a proxy or API key.
### Optional triathlon source: 대한철인3종협회
- list entry point: `https://triathlon.or.kr/events/tour/?sYear=<YYYY>&vType=list`
- detail pages: same-host `triathlon.or.kr` links matching `/events/tour/overview/?mode=overview&tourcd=<id>`
- detail fields used: title, event date, venue, registration period, organizer, and course/category labels. Non-competition list entries such as education, seminars, notices, and referee/leader sessions are filtered out before detail fetch.
- reason selected: the official federation page is public and unauthenticated, and provides triathlon schedules when available.
## Workflow
### 1. Search schedules
```js
const { searchEvents } = require("korean-marathon-schedule")
const result = await searchEvents({
query: "서울", // title, venue, region, or category filter. Optional.
from: "2026-05-01", // optional YYYY-MM-DD
to: "2026-12-31", // optional YYYY-MM-DD
includeTriathlon: true, // optional; default false
limit: 10, // optional; default 10
maxDetailsPerSource: 100 // optional crawl budget; default max(300, limit * 10)
})
console.log(result.items)
```
CLI:
```bash
node packages/korean-marathon-schedule/src/cli.js 서울 --from 2026-05-01 --to 2026-12-31 --include-triathlon --limit 10 --max-details-per-source 100
```
### 2. Summarize conservatively
For each event, show:
```text
- 대회명: ...
일정: ...
장소: ...
신청 마감: ...
종목: ...
링크: ...
```
If no deadline is present, say `신청 마감일을 공개 페이지에서 확인하지 못함` instead of guessing.
### 3. Use fallback order
1. GoRunning list → same-host GoRunning detail pages for marathon/road-running schedules; continue through the public list until enough matching results are collected, the list is exhausted, or the explicit per-source detail budget is reached.
2. If the user asks for triathlon or `includeTriathlon` is useful, query the 대한철인3종협회 year list and same-host public detail pages; skip non-competition list entries and continue until enough matching results are collected, the selected year lists are exhausted, or the explicit per-source detail budget shared across selected years is reached.
3. If either source returns an empty, blocked, changed page, or detail-budget warning, report the source-specific failure/warning and return any successfully parsed results from the other source.
## Done when
- User's location/date/category filter was applied or explicitly left broad.
- At least one available result is summarized, or a clear empty-result/failure reason is given.
- Venue, registration deadline/period, and categories are included when present.
- Triathlon events were included when requested or when the user asked for them as "가능하면".
## Failure modes
- 일정/접수 정보는 수시로 바뀔 수 있다; always state results are based on the current public page read.
- GoRunning or triathlon.or.kr HTML structure may change; then parsing may return empty fields or fail. Off-origin detail links are ignored to keep the lookup bounded to documented public sources. If a public list is larger than the per-source detail budget, results can be partial and a warning is returned; triathlon applies that budget once across all selected years.
- Some official event websites may be linked only from the detail page; if absent, return the source detail URL.
- Registration may already be closed even if the event date is upcoming.
- Login, payment, CAPTCHA, or private member-only pages are outside scope and must not be automated.
## Notes
- This is a read-only lookup skill.
- No k-skill-proxy route is used because the upstream surfaces are public and do not require API keys.
- Do not register, reserve, pay for, or modify race entries.

14
package-lock.json generated
View file

@ -1037,6 +1037,10 @@
"resolved": "packages/kleague-results",
"link": true
},
"node_modules/korean-marathon-schedule": {
"resolved": "packages/korean-marathon-schedule",
"link": true
},
"node_modules/lck-analytics": {
"resolved": "packages/lck-analytics",
"link": true
@ -1836,6 +1840,16 @@
"node": ">=18"
}
},
"packages/korean-marathon-schedule": {
"version": "0.1.0",
"license": "MIT",
"bin": {
"korean-marathon-schedule": "src/cli.js"
},
"engines": {
"node": ">=18"
}
},
"packages/lck-analytics": {
"version": "0.4.0",
"license": "MIT",

View file

@ -12,7 +12,7 @@
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py coupang-product-search/scripts/coupang_partners_mcp.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"typecheck": "tsc --noEmit",
"test": "node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && 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",
"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",
"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

@ -27,8 +27,6 @@
- `GET /v1/data4library/book-exists` — 도서관별 도서 소장여부(`DATA4LIBRARY_AUTH_KEY`)
- `GET /v1/lh-notice/search` — LH 청약 공고 목록(`DATA_GO_KR_API_KEY`)
- `GET /v1/lh-notice/detail` — LH 청약 공고 상세(`DATA_GO_KR_API_KEY`)
- `GET /v1/sh-notice/search` — SH 서울주택도시개발공사 공고/공지 목록(공식 i-sh.co.kr HTML 게시판; 무인증)
- `GET /v1/sh-notice/detail` — SH 공고/공지 상세 본문 및 첨부 미리보기 링크(무인증)
## `/health` 업스트림 플래그 의미
@ -205,21 +203,6 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/lh-notice/detail' \
--data-urlencode 'splInfTpCd=051'
```
SH 공고/공지 검색 예시 (공식 `i-sh.co.kr` HTML 게시판, 무인증). 키워드만 보내면 자동으로 제목 검색으로 처리되고, `pageSize` 는 SH 게시판 응답 한 페이지 분량인 10건으로 캡됩니다:
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/sh-notice/search' \
--data-urlencode 'q=행복주택' \
--data-urlencode 'pageSize=10'
```
SH 공고/공지 상세:
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/sh-notice/detail' \
--data-urlencode 'seq=303994'
```
프록시는 내부적으로 `waterlevel/info.json` 으로 관측소를 해석하고, `waterlevel/list/10M/{WLOBSCD}.json` 으로 최신 수위/유량을 조회합니다. 한국 주식 route는 KRX Open API에 `AUTH_KEY` 헤더를 서버 쪽에서만 주입합니다.

View file

@ -9,7 +9,7 @@
"node": ">=18"
},
"scripts": {
"lint": "node --check src/airkorea.js && node --check src/bluer.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/lh-notice.js && node --check src/sh-notice.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-news.js && node --check src/naver-shopping.js && node --check src/parking-lots.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/lh-notice.test.js && node --check test/sh-notice.test.js && node --check test/molit.test.js && node --check test/naver-news.test.js && node --check test/naver-shopping.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
"lint": "node --check src/airkorea.js && node --check src/bluer.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/lh-notice.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-news.js && node --check src/naver-shopping.js && node --check src/parking-lots.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/lh-notice.test.js && node --check test/molit.test.js && node --check test/naver-news.test.js && node --check test/naver-shopping.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
"test": "node --test"
},
"dependencies": {

View file

@ -19,12 +19,6 @@ const {
normalizeLhNoticeDetailQuery,
normalizeLhNoticeSearchQuery
} = require("./lh-notice");
const {
fetchShNoticeDetail,
fetchShNoticeList,
normalizeShNoticeDetailQuery,
normalizeShNoticeSearchQuery
} = require("./sh-notice");
const { fetchTransactions, VALID_ASSET_TYPES, VALID_DEAL_TYPES } = require("./molit");
const { fetchNaverNewsSearch, normalizeNaverNewsSearchQuery } = require("./naver-news");
const { fetchNaverShoppingSearch, normalizeNaverShoppingSearchQuery } = require("./naver-shopping");
@ -2258,118 +2252,6 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
return payload;
});
app.get("/v1/sh-notice/search", async (request, reply) => {
let normalized;
try {
normalized = normalizeShNoticeSearchQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "sh-notice-search",
...normalized
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: { hit: true, ttl_ms: config.cacheTtlMs }
}
};
}
let body;
try {
body = await fetchShNoticeList({ filters: normalized });
} catch (error) {
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
return {
error: error.code || "proxy_error",
message: error.message,
proxy: {
name: config.proxyName,
cache: { hit: false, ttl_ms: config.cacheTtlMs }
}
};
}
const payload = {
...body,
proxy: {
name: config.proxyName,
cache: { hit: false, ttl_ms: config.cacheTtlMs },
requested_at: new Date().toISOString()
}
};
cache.set(cacheKey, payload, config.cacheTtlMs);
return payload;
});
app.get("/v1/sh-notice/detail", async (request, reply) => {
let normalized;
try {
normalized = normalizeShNoticeDetailQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "sh-notice-detail",
...normalized
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: { hit: true, ttl_ms: config.cacheTtlMs }
}
};
}
let body;
try {
body = await fetchShNoticeDetail({ filters: normalized });
} catch (error) {
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
return {
error: error.code || "proxy_error",
message: error.message,
proxy: {
name: config.proxyName,
cache: { hit: false, ttl_ms: config.cacheTtlMs }
}
};
}
const payload = {
...body,
proxy: {
name: config.proxyName,
cache: { hit: false, ttl_ms: config.cacheTtlMs },
requested_at: new Date().toISOString()
}
};
cache.set(cacheKey, payload, config.cacheTtlMs);
return payload;
});
app.get("/v1/mfds/drug-safety/lookup", async (request, reply) => {
let normalized;

View file

@ -1,299 +0,0 @@
// SH 공고/공지 (Seoul Housing & Communities Corporation) scraper.
// SH does not expose the same data.go.kr envelope as LH for this board, so this
// wrapper reads the official i-sh.co.kr board HTML and normalizes list/detail rows.
const SH_BASE_URL = "https://www.i-sh.co.kr";
const SH_NOTICE_PATH = "/app/lay2/program/S48T1581C563/www/brd/m_247";
const SH_LIST_URL = `${SH_BASE_URL}${SH_NOTICE_PATH}/list.do`;
const SH_VIEW_URL = `${SH_BASE_URL}${SH_NOTICE_PATH}/view.do`;
const DEFAULT_MULTI_ITM_SEQ = "2";
function trimOrNull(value) {
if (value === undefined || value === null) return null;
const trimmed = String(value).replace(/\s+/g, " ").trim();
return trimmed || null;
}
function decodeHtml(value) {
if (value === undefined || value === null) return "";
return String(value)
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x27;/g, "'")
.replace(/&nbsp;/g, " ");
}
function stripTags(html) {
return decodeHtml(String(html || "").replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<[^>]+>/g, " "));
}
function absolutizeUrl(url) {
const clean = decodeHtml(url || "").trim();
if (!clean) return null;
return new URL(clean, SH_BASE_URL).toString();
}
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 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 parseBoundedInt(value, { defaultValue, min, 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 (parsed > max) return max;
return parsed;
}
function normalizeMultiItmSeq(value) {
const normalized = trimOrNull(value);
if (!normalized) return DEFAULT_MULTI_ITM_SEQ;
if (!/^\d{1,10}$/.test(normalized)) throw new Error("multiItmSeq must be digits only.");
return normalized;
}
function normalizeShNoticeSearchQuery(query) {
const srchTp = trimOrNull(query.srchTp ?? query.searchType ?? query.type);
if (srchTp && !["title", "content", "1", "2", "제목", "내용"].includes(srchTp)) {
throw new Error("srchTp must be title/content or 제목/내용.");
}
const mappedSrchTp = srchTp === "title" || srchTp === "제목" ? "1" : srchTp === "content" || srchTp === "내용" ? "2" : srchTp;
const srchWord = trimOrNull(query.srchWord ?? query.q ?? query.query ?? query.keyword);
if (srchWord && srchWord.length > 100) {
throw new Error("srchWord must be 100 characters or fewer.");
}
// SH 게시판은 srchWord만 있고 srchTp가 없으면 키워드를 무시하고 전체 목록을 돌려준다.
// 키워드만 들어온 경우 명시적 의도가 없을 때 제목 검색(`1`)로 fallback 한다.
const resolvedSrchTp = mappedSrchTp || (srchWord ? "1" : null);
// SH 게시판은 응답 페이지에 10건 고정으로 내려주므로, pageSize를 10으로 캡한다.
// 값이 더 크면 clamp되며 응답 summary.page_size에도 실제로 반환된 캡 값이 반영된다.
return {
page: parseBoundedInt(query.page ?? query.pageNo, { defaultValue: 1, min: 1, max: 1000, label: "page" }),
pageSize: parseBoundedInt(query.pageSize ?? query.limit, { defaultValue: 10, min: 1, max: 10, label: "pageSize" }),
srchWord,
srchTp: resolvedSrchTp,
multiItmSeq: normalizeMultiItmSeq(query.multiItmSeq ?? query.multi_itm_seq)
};
}
function normalizeShNoticeDetailQuery(query) {
const seq = trimOrNull(query.seq ?? query.noticeSeq ?? query.id);
if (!seq) throw new Error("Provide seq.");
if (!/^\d{1,20}$/.test(seq)) throw new Error("seq must be digits only.");
return {
seq,
multiItmSeq: normalizeMultiItmSeq(query.multiItmSeq ?? query.multi_itm_seq)
};
}
function buildSearchUrl(filters) {
const url = new URL(SH_LIST_URL);
url.searchParams.set("multi_itm_seq", filters.multiItmSeq || DEFAULT_MULTI_ITM_SEQ);
if (filters.page) url.searchParams.set("page", String(filters.page));
if (filters.srchWord) url.searchParams.set("srchWord", filters.srchWord);
if (filters.srchTp) url.searchParams.set("srchTp", filters.srchTp);
return url;
}
function buildDetailUrl(filters) {
const url = new URL(SH_VIEW_URL);
url.searchParams.set("multi_itm_seq", filters.multiItmSeq || DEFAULT_MULTI_ITM_SEQ);
url.searchParams.set("seq", filters.seq);
return url;
}
function extractTotalCount(html) {
const text = stripTags(html);
const match = text.match(/총\s*([0-9,]+)\s*건/);
return match ? Number.parseInt(match[1].replace(/,/g, ""), 10) : null;
}
function parseListRows(html, filters = {}) {
const tbodyMatch = String(html || "").match(/<tbody[^>]*>([\s\S]*?)<\/tbody>/i);
const tbody = tbodyMatch ? tbodyMatch[1] : String(html || "");
const rows = [];
const rowRegex = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
let rowMatch;
while ((rowMatch = rowRegex.exec(tbody))) {
const row = rowMatch[1];
const seqMatch = row.match(/getDetailView\(['"]?(\d+)['"]?\)/i);
if (!seqMatch) continue;
const cells = [...row.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)].map((m) => m[1]);
if (cells.length < 5) continue;
const titleAnchor = cells[1].match(/<a[^>]*>([\s\S]*?)<\/a>/i);
const rawTitle = (titleAnchor ? titleAnchor[1] : cells[1]).replace(/<span[^>]*class=["'][^"']*icoNew[^"']*["'][^>]*>[\s\S]*?<\/span>/gi, " ");
const title = trimOrNull(stripTags(rawTitle).replace(/^NEW\s*/i, ""));
const seq = seqMatch[1];
rows.push({
seq,
number: trimOrNull(stripTags(cells[0])),
title,
department: trimOrNull(stripTags(cells[2])),
registered_date: trimOrNull(stripTags(cells[3])),
views: (() => {
const v = trimOrNull(stripTags(cells[4]));
return v && /^[0-9,]+$/.test(v) ? Number.parseInt(v.replace(/,/g, ""), 10) : null;
})(),
is_new: /icoNew/i.test(cells[1]),
detail_url: buildDetailUrl({ seq, multiItmSeq: filters.multiItmSeq || DEFAULT_MULTI_ITM_SEQ }).toString()
});
}
return rows;
}
function parseAttachments(html, _seq) {
const attachments = [];
// SH 상세 페이지의 첨부 셀 안에는 (1) 확장자별 아이콘 템플릿(`.pdf`, `.hwp` ...)이
// 주석 처리된 영역에 먼저 있고, (2) 실제 첨부는 `onclick="existFile('N')"` 가 달린
// `btnAttach` 앵커로 따로 등장한다. 단순히 첫 `btnAttach`를 잡으면 아이콘 라벨이 잡힌다.
const source = String(html || "").replace(/<!--[\s\S]*?-->/g, " ");
const rowRegex = /<tr[^>]*>[\s\S]*?<th[^>]*>\s*첨부(?:파일)?\s*<\/th>[\s\S]*?<td[^>]*>([\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 {
attrs,
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) => absolutizeUrl(href))
.filter(Boolean);
const btnAttachAnchors = anchors.filter((anchor) => /\bbtnAttach\b/i.test(anchor.className));
const realFileAnchors = btnAttachAnchors.filter(
(anchor) => /existFile\(\s*['"]?\d+['"]?\s*\)/i.test(anchor.onclick) && !isAttachmentIconLabel(anchor.text)
);
const fallbackFileAnchors = btnAttachAnchors.filter((anchor) => !isAttachmentIconLabel(anchor.text));
const fileAnchors = realFileAnchors.length > 0 ? realFileAnchors : fallbackFileAnchors;
fileAnchors.forEach((anchor, index) => {
const previewUrl = previewUrls[index] || null;
const fileSeqMatch = previewUrl ? previewUrl.match(/[?&]file_seq=(\d+)/) : null;
attachments.push({
filename: anchor.text,
file_seq: fileSeqMatch ? fileSeqMatch[1] : null,
preview_url: previewUrl
});
});
}
return attachments;
}
function parseDetail(html, filters) {
const titleMatch = String(html || "").match(/<caption>([\s\S]*?)<\/caption>/i) || String(html || "").match(/<thead>[\s\S]*?<th[^>]*colspan=["']2["'][^>]*>([\s\S]*?)<\/th>/i);
const title = trimOrNull(stripTags(titleMatch ? titleMatch[1] : ""));
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[^>]*class=["']cont["'][^>]*>([\s\S]*?)<\/td>/i);
const contentText = trimOrNull(stripTags(contentMatch ? contentMatch[1] : ""));
return {
seq: filters.seq,
title,
registered_date: registeredMatch ? registeredMatch[1].replace(/\./g, "-") : null,
views: viewsMatch ? Number.parseInt(viewsMatch[1].replace(/,/g, ""), 10) : null,
content_text: contentText,
attachments: parseAttachments(html, filters.seq),
detail_url: buildDetailUrl(filters).toString()
};
}
function buildListResponseBody(html, filters) {
const allItems = parseListRows(html, filters);
const items = allItems.slice(0, filters.pageSize);
return {
items,
summary: {
page: filters.page,
page_size: filters.pageSize,
returned_count: items.length,
total_count: extractTotalCount(html)
},
query: {
srch_word: filters.srchWord || null,
srch_tp: filters.srchTp || null,
multi_itm_seq: filters.multiItmSeq || DEFAULT_MULTI_ITM_SEQ
}
};
}
function buildDetailResponseBody(html, filters) {
return {
notice: parseDetail(html, filters),
query: {
seq: filters.seq,
multi_itm_seq: filters.multiItmSeq || DEFAULT_MULTI_ITM_SEQ
}
};
}
async function fetchText(url, { fetchImpl = global.fetch, timeoutMs = 20000 } = {}) {
let response;
try {
response = await fetchImpl(url.toString(), {
headers: { Accept: "text/html,application/xhtml+xml" },
signal: AbortSignal.timeout(timeoutMs)
});
} catch (err) {
const error = new Error(`SH upstream request failed: ${err.message}`);
error.statusCode = 502;
error.code = "upstream_fetch_failed";
throw error;
}
const text = await response.text();
if (!response.ok) {
const error = new Error(`SH upstream responded with HTTP ${response.status}: ${text.slice(0, 200)}`);
error.statusCode = 502;
error.code = "upstream_error";
throw error;
}
return text;
}
async function fetchShNoticeList({ filters, fetchImpl = global.fetch }) {
const html = await fetchText(buildSearchUrl(filters), { fetchImpl });
return buildListResponseBody(html, filters);
}
async function fetchShNoticeDetail({ filters, fetchImpl = global.fetch }) {
const html = await fetchText(buildDetailUrl(filters), { fetchImpl });
return buildDetailResponseBody(html, filters);
}
module.exports = {
SH_BASE_URL,
SH_NOTICE_PATH,
SH_LIST_URL,
SH_VIEW_URL,
DEFAULT_MULTI_ITM_SEQ,
normalizeShNoticeSearchQuery,
normalizeShNoticeDetailQuery,
buildSearchUrl,
buildDetailUrl,
parseListRows,
parseAttachments,
parseDetail,
buildListResponseBody,
buildDetailResponseBody,
fetchShNoticeList,
fetchShNoticeDetail
};

View file

@ -1,151 +0,0 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
buildDetailResponseBody,
buildListResponseBody,
buildSearchUrl,
normalizeShNoticeDetailQuery,
normalizeShNoticeSearchQuery,
parseAttachments,
parseDetail,
parseListRows
} = require("../src/sh-notice");
const LIST_HTML = `
<p> <strong>1606</strong>, 1/161</p>
<table><tbody>
<tr>
<td>1606</td>
<td class="txtL"><a href="#" class="ellipsis icon" onclick="javascript:getDetailView('304022');return false;"><span class="icoNew">NEW</span> () </a></td>
<td>시스템운영부</td>
<td class="num">2026-05-08</td>
<td class="num">97</td>
</tr>
<tr>
<td>1605</td>
<td class="txtL"><a href="#" class="ellipsis" onclick="javascript:getDetailView('303994');return false;">행복주택 예비당첨자 게시</a></td>
<td>공공주택공급부</td>
<td class="num">2026-05-07</td>
<td class="num">1,972</td>
</tr>
</tbody></table>`;
const DETAIL_HTML = `
<table>
<caption>행복주택 예비당첨자 게시</caption>
<tbody>
<tr><td><strong>등록일 :</strong> 2026-05-07 <strong> :</strong> 1,972</td></tr>
<tr><th scope="row">첨부</th><td>
<a href="#" class="btnAttach v1">.pdf</a>
<a href="#" class="btnAttach v2">.hwp</a>
<a href="#" class="btnAttach v11">.etc</a>
<a href="#" class="btnAttach v1" onclick="existFile('0'); return false;">2022 2 행복주택 예비 17 당첨자명단.pdf</a>
<a href="/app/com/util/htmlConverter.do?brd_id=GS0401&amp;seq=303994&amp;data_tp=A&amp;file_seq=1" class="btn btnWhite h32 icoView" target="_blank">미리보기</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=303994&amp;data_tp=A&amp;file_seq=2" class="btn btnWhite h32 icoView" target="_blank">미리보기</a>
</td></tr>
<tr><td colspan="2" class="cont"><p>2022 2 행복주택 예비17차 당첨자 발표</p><p> .</p></td></tr>
</tbody>
</table>`;
test("normalizeShNoticeSearchQuery maps aliases and bounds page size", () => {
const normalized = normalizeShNoticeSearchQuery({ q: "행복주택", searchType: "제목", page: "2", limit: "200" });
assert.equal(normalized.srchWord, "행복주택");
assert.equal(normalized.srchTp, "1");
assert.equal(normalized.page, 2);
assert.equal(normalized.pageSize, 10);
});
test("normalizeShNoticeSearchQuery defaults keyword search to title scope when srchTp is omitted", () => {
const normalized = normalizeShNoticeSearchQuery({ q: "행복주택" });
assert.equal(normalized.srchWord, "행복주택");
assert.equal(normalized.srchTp, "1");
});
test("normalizeShNoticeSearchQuery keeps srchTp null when no keyword is provided", () => {
const normalized = normalizeShNoticeSearchQuery({});
assert.equal(normalized.srchWord, null);
assert.equal(normalized.srchTp, null);
});
test("normalizeShNoticeSearchQuery preserves explicit content scope", () => {
const normalized = normalizeShNoticeSearchQuery({ q: "행복주택", srchTp: "content" });
assert.equal(normalized.srchTp, "2");
});
test("normalizeShNoticeSearchQuery rejects oversized keyword", () => {
assert.throws(
() => normalizeShNoticeSearchQuery({ q: "x".repeat(101) }),
/100 characters/
);
});
test("normalizeShNoticeSearchQuery rejects non-numeric multiItmSeq", () => {
assert.throws(() => normalizeShNoticeSearchQuery({ multiItmSeq: "abc" }), /digits only/);
});
test("normalizeShNoticeDetailQuery requires numeric seq", () => {
assert.equal(normalizeShNoticeDetailQuery({ id: "303994" }).seq, "303994");
assert.throws(() => normalizeShNoticeDetailQuery({ seq: "abc" }), /digits only/);
});
test("normalizeShNoticeDetailQuery rejects non-numeric multiItmSeq", () => {
assert.throws(
() => normalizeShNoticeDetailQuery({ seq: "303994", multiItmSeq: "abc" }),
/digits only/
);
});
test("buildSearchUrl targets official SH list page", () => {
const url = buildSearchUrl(normalizeShNoticeSearchQuery({ keyword: "원룸", srchTp: "content" }));
assert.equal(url.hostname, "www.i-sh.co.kr");
assert.equal(url.searchParams.get("srchTp"), "2");
assert.equal(url.searchParams.get("srchWord"), "원룸");
});
test("parseListRows extracts SH notice list", () => {
const rows = parseListRows(LIST_HTML, { multiItmSeq: "2" });
assert.equal(rows.length, 2);
assert.deepEqual(rows[0], {
seq: "304022",
number: "1606",
title: "전산작업에 따른 서비스(신한인증서) 이용 안내",
department: "시스템운영부",
registered_date: "2026-05-08",
views: 97,
is_new: true,
detail_url: "https://www.i-sh.co.kr/app/lay2/program/S48T1581C563/www/brd/m_247/view.do?multi_itm_seq=2&seq=304022"
});
assert.equal(rows[1].views, 1972);
});
test("buildListResponseBody limits returned items and includes total", () => {
const body = buildListResponseBody(LIST_HTML, { page: 1, pageSize: 1, multiItmSeq: "2" });
assert.equal(body.items.length, 1);
assert.equal(body.summary.total_count, 1606);
assert.equal(body.summary.returned_count, 1);
});
test("parseAttachments skips icon-template anchors and returns real attachments with previews", () => {
const attachments = parseAttachments(DETAIL_HTML, "303994");
assert.equal(attachments.length, 2);
assert.equal(attachments[0].filename, "2022년 2차 행복주택 예비 17차 당첨자명단.pdf");
assert.equal(attachments[0].file_seq, "1");
assert.equal(
attachments[0].preview_url,
"https://www.i-sh.co.kr/app/com/util/htmlConverter.do?brd_id=GS0401&seq=303994&data_tp=A&file_seq=1"
);
assert.equal(Object.hasOwn(attachments[0], "download_hint"), false);
assert.equal(attachments[1].filename, "추가 안내문.hwp");
assert.equal(attachments[1].file_seq, "2");
});
test("parseDetail extracts title, metadata, and content text", () => {
const detail = parseDetail(DETAIL_HTML, { seq: "303994", multiItmSeq: "2" });
assert.equal(detail.title, "행복주택 예비당첨자 게시");
assert.equal(detail.registered_date, "2026-05-07");
assert.equal(detail.views, 1972);
assert.match(detail.content_text, /계약 안내/);
assert.equal(detail.attachments.length, 2);
});

View file

@ -0,0 +1,35 @@
# korean-marathon-schedule
Public Korean marathon and triathlon schedule lookup client for the `korean-marathon-schedule` k-skill.
## Sources
- Marathon/road-running: `https://gorunning.kr/races/` public race list and same-host public race detail pages.
- Triathlon: `https://triathlon.or.kr/events/tour/?sYear=<year>&vType=list` and same-host public federation detail pages; non-competition education/admin entries are skipped.
Both sources are unauthenticated public web surfaces. No proxy or API key is required. Off-origin detail links are ignored, and searches continue through source lists until enough matching results are collected, the source list is exhausted, or the configurable per-source detail budget is reached. The triathlon budget is shared across all selected year lists. The default budget is `max(300, limit * 10)`; when a budget is exhausted before the source list ends, a warning is returned.
## Usage
```js
const { searchEvents } = require("korean-marathon-schedule")
const result = await searchEvents({
query: "서울",
from: "2026-05-01",
to: "2026-12-31",
includeTriathlon: true,
limit: 5,
maxDetailsPerSource: 100
})
console.log(result.items)
```
CLI:
```bash
npx korean-marathon-schedule 서울 --from 2026-05-01 --to 2026-12-31 --include-triathlon --limit 5 --max-details-per-source 100
```
Returned event fields include `title`, `eventDate`, `region`, `venue`, `registrationDeadline`, `registrationPeriod`, `categories`, `organizer`, `officialUrl`, and source `url`.

View file

@ -0,0 +1,35 @@
{
"name": "korean-marathon-schedule",
"version": "0.1.0",
"description": "Public Korean marathon and triathlon schedule lookup client",
"license": "MIT",
"main": "src/index.js",
"bin": {
"korean-marathon-schedule": "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",
"marathon",
"running",
"triathlon",
"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,43 @@
#!/usr/bin/env node
const { searchEvents } = require("./index")
async function main() {
const args = parseArgs(process.argv.slice(2))
const result = await searchEvents(args)
console.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") options.query = argv[++i] || ""
else if (arg === "--from") options.from = argv[++i]
else if (arg === "--to") options.to = argv[++i]
else if (arg === "--limit") options.limit = Number(argv[++i])
else if (arg === "--max-details-per-source") options.maxDetailsPerSource = Number(argv[++i])
else if (arg === "--include-triathlon") options.includeTriathlon = true
else if (arg === "--help" || arg === "-h") {
printHelp()
process.exit(0)
} else if (!options.query) {
options.query = arg
}
}
return options
}
function printHelp() {
console.log(`Usage: korean-marathon-schedule [query] [options]\n\nOptions:\n -q, --query <text> Filter by title, region, venue, or category\n --from <YYYY-MM-DD> Earliest event date\n --to <YYYY-MM-DD> Latest event date\n --limit <number> Maximum results (default: 10)\n --max-details-per-source <number>\n Detail crawl budget for each public source\n --include-triathlon Include 대한철인3종협회 triathlon events when possible\n`)
}
function run() {
return main().catch((error) => {
console.error(error && error.stack ? error.stack : String(error))
process.exitCode = 1
})
}
if (require.main === module) run()
module.exports = { parseArgs, printHelp, main }

View file

@ -0,0 +1,472 @@
const GORUNNING_RACES_URL = "https://gorunning.kr/races/"
const TRIATHLON_TOUR_URL = "https://triathlon.or.kr/events/tour/"
async function searchEvents(options = {}) {
const {
query = "",
from,
to,
includeTriathlon = false,
limit = 10,
maxDetailsPerSource,
fetcher = global.fetch
} = options
if (!fetcher) throw new Error("fetch is required.")
const normalizedLimit = Math.max(1, Number(limit) || 10)
const detailBudget = normalizeDetailBudget(maxDetailsPerSource, normalizedLimit)
const years = collectYears(from, to)
const items = []
const warnings = []
try {
const marathonListHtml = await fetchText(fetcher, GORUNNING_RACES_URL)
const marathonUrls = parseGorunningList(marathonListHtml)
const marathonBudgetedUrls = marathonUrls.slice(0, detailBudget)
for (const url of marathonBudgetedUrls) {
try {
const detailHtml = await fetchText(fetcher, url)
const event = parseGorunningDetail(detailHtml, url)
if (matchesEvent(event, { query, from, to })) items.push(event)
} catch (error) {
warnings.push(`gorunning detail failed for ${url}: ${error.message}`)
}
if (items.length >= normalizedLimit) break
}
if (items.length < normalizedLimit && marathonUrls.length > marathonBudgetedUrls.length) {
warnings.push(`gorunning detail budget exhausted after ${marathonBudgetedUrls.length} of ${marathonUrls.length} source links`)
}
} catch (error) {
warnings.push(`gorunning source failed: ${error.message}`)
}
if (includeTriathlon) {
let triathlonDetailCount = 0
let triathlonSourceCount = 0
for (const year of years) {
const listUrl = `${TRIATHLON_TOUR_URL}?sYear=${encodeURIComponent(year)}&vType=list`
try {
const triListHtml = await fetchText(fetcher, listUrl)
const triListItems = parseTriathlonList(triListHtml)
triathlonSourceCount += triListItems.length
for (const listItem of triListItems) {
if (triathlonDetailCount >= detailBudget) break
triathlonDetailCount += 1
try {
const detailHtml = await fetchText(fetcher, listItem.url)
const event = parseTriathlonDetail(detailHtml, listItem.url, listItem)
if (matchesEvent(event, { query, from, to })) items.push(event)
} catch (error) {
warnings.push(`triathlon detail failed for ${listItem.url}: ${error.message}`)
}
if (items.length >= normalizedLimit) break
}
} catch (error) {
warnings.push(`triathlon source failed for ${listUrl}: ${error.message}`)
}
if (items.length >= normalizedLimit) break
}
if (items.length < normalizedLimit && triathlonSourceCount > triathlonDetailCount && triathlonDetailCount >= detailBudget) {
warnings.push(`triathlon detail budget exhausted after ${triathlonDetailCount} of ${triathlonSourceCount} source links`)
}
}
items.sort((a, b) => String(a.eventDate || "").localeCompare(String(b.eventDate || "")))
return {
query: String(query || ""),
from: from || null,
to: to || null,
includeTriathlon: Boolean(includeTriathlon),
sources: includeTriathlon ? ["gorunning", "triathlon.or.kr"] : ["gorunning"],
warnings,
items: items.slice(0, normalizedLimit)
}
}
function normalizeDetailBudget(maxDetailsPerSource, normalizedLimit) {
if (maxDetailsPerSource === undefined || maxDetailsPerSource === null) return Math.max(300, normalizedLimit * 10)
const numeric = Number(maxDetailsPerSource)
if (!Number.isFinite(numeric)) return Math.max(300, normalizedLimit * 10)
return Math.max(1, Math.floor(numeric))
}
function parseGorunningList(html) {
const urls = new Set()
const source = String(html || "")
const linkRe = /<a\b[^>]*href=["']([^"']*\/races\/\d+\/[^"']*)["'][^>]*>/gi
let match
while ((match = linkRe.exec(source))) {
const url = resolveAllowedUrl(decodeHtml(match[1]), GORUNNING_RACES_URL, "gorunning.kr")
if (url) urls.add(url)
}
return [...urls]
}
function parseGorunningDetail(html, url) {
const title = firstHeading(html) || textBetweenLabels(html, "대회명") || ""
const plain = htmlToText(html)
const registrationPeriod = parseRegistrationPeriod(plain)
const eventDate = parseFirstDateAfterTitle(plain, title) || parseFirstIsoDate(plain)
const address = textBetweenLabels(html, "주소")
const locationLine = findLocationLine(plain)
const region = inferRegion([address, locationLine], plain)
const venue = address || stripRegion(locationLine, region) || locationLine || ""
const officialUrl = findOfficialUrl(html, url)
const categories = extractGorunningCategories(plain, title)
return compactEvent({
source: "gorunning",
type: "marathon",
title: cleanText(title),
eventDate,
region,
venue: cleanText(venue),
registrationDeadline: registrationPeriod.end || parseDeadline(plain, eventDate),
registrationPeriod,
status: detectStatus(plain),
categories,
organizer: textBetweenLabels(html, "주최자") || null,
officialUrl,
url
})
}
function parseTriathlonList(html) {
const items = new Map()
const source = String(html || "")
const linkRe = /<a\b[^>]*href=["']([^"']*\/events\/tour\/overview\/[^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi
let match
while ((match = linkRe.exec(source))) {
const url = resolveAllowedUrl(decodeHtml(match[1]), "https://triathlon.or.kr", "triathlon.or.kr")
if (!url) continue
const title = cleanText(htmlToText(match[2]))
const context = enclosingTagSource(source, match.index, "tr") || source.slice(Math.max(0, match.index - 300), Math.min(source.length, match.index + 700))
const contextText = htmlToText(context)
if (!isTriathlonCompetitionText(title, contextText)) continue
const categories = splitCategories(textAfterInlineLabel(contextText, "코스"))
items.set(url, { url, categories })
}
return [...items.values()]
}
function enclosingTagSource(source, index, tagName) {
const tag = escapeRegExp(tagName)
const before = source.slice(0, index)
const openMatch = [...before.matchAll(new RegExp(`<${tag}\\b[^>]*>`, "gi"))].pop()
if (!openMatch) return null
const closeRe = new RegExp(`</${tag}>`, "i")
const closeMatch = closeRe.exec(source.slice(index))
if (!closeMatch) return null
return source.slice(openMatch.index, index + closeMatch.index + closeMatch[0].length)
}
function resolveAllowedUrl(href, baseUrl, allowedHostname) {
try {
const url = new URL(href, baseUrl)
return url.hostname === allowedHostname ? url.toString() : null
} catch {
return null
}
}
function isTriathlonCompetitionText(title, context = "") {
const titleText = cleanText(title)
const contextText = cleanText(context)
if (!titleText && !contextText) return false
if (/교육|강습|세미나|설명회|회의|공지|대회규정|심판|지도자|워크숍/.test(titleText)) return false
if (/대회|컵|선수권|챔피언십|철인3종|트라이애슬론|듀애슬론|아쿠아슬론/.test(titleText)) return true
if (/교육|강습|세미나|설명회|회의|공지|대회규정|심판|지도자|워크숍/.test(contextText)) return false
return /대회|컵|선수권|챔피언십|철인3종|트라이애슬론|듀애슬론|아쿠아슬론/.test(contextText)
}
function parseTriathlonDetail(html, url, listMetadata = {}) {
const title = tableValue(html, "대회명") || firstHeading(html) || ""
const eventDate = normalizeDate(tableValue(html, "대회기간") || tableValue(html, "대회일정") || htmlToText(html))
const venue = tableValue(html, "대회장소") || textAfterInlineLabel(htmlToText(html), "장소") || ""
const registrationPeriod = parseRegistrationPeriod(tableValue(html, "접수기간") || htmlToText(html))
const courseText = textAfterInlineLabel(htmlToText(html), "코스") || tableValue(html, "종목") || ""
const detailCategories = splitCategories(courseText)
return compactEvent({
source: "triathlon.or.kr",
type: "triathlon",
title: cleanText(title),
eventDate,
region: normalizeRegion(String(venue).split(/\s+/)[0]),
venue: cleanText(venue),
registrationDeadline: registrationPeriod.end,
registrationPeriod,
status: detectStatus(htmlToText(html)),
categories: detailCategories.length ? detailCategories : (listMetadata.categories || []),
organizer: tableValue(html, "주최") || tableValue(html, "주관") || null,
officialUrl: url,
url
})
}
async function fetchText(fetcher, url) {
const response = await fetcher(url, {
headers: {
"user-agent": "Mozilla/5.0 (compatible; k-skill/korean-marathon-schedule)",
accept: "text/html,application/xhtml+xml"
}
})
if (!response || !response.ok) {
const status = response ? `${response.status} ${response.statusText || ""}`.trim() : "no response"
throw new Error(`request failed for ${url}: ${status}`)
}
return response.text()
}
function matchesEvent(event, { query, from, to }) {
const q = cleanText(query || "").toLowerCase()
if (q) {
const haystack = [event.title, event.region, event.venue, ...(event.categories || [])].join(" ").toLowerCase()
if (!haystack.includes(q)) return false
}
if (from && event.eventDate && event.eventDate < from) return false
if (to && event.eventDate && event.eventDate > to) return false
return true
}
function collectYears(from, to) {
const current = new Date().getFullYear()
const start = from && /^\d{4}/.test(from) ? Number(from.slice(0, 4)) : current
const end = to && /^\d{4}/.test(to) ? Number(to.slice(0, 4)) : start
const years = []
for (let year = start; year <= Math.min(end, start + 2); year += 1) years.push(String(year))
return years.length ? years : [String(current)]
}
function firstHeading(html) {
const match = String(html || "").match(/<h[1-3][^>]*>([\s\S]*?)<\/h[1-3]>/i)
return match ? cleanText(htmlToText(match[1])) : null
}
function tableValue(html, label) {
const source = String(html || "")
const escaped = escapeRegExp(label)
const patterns = [
new RegExp(`<tr[^>]*>[\\s\\S]*?<t[hd][^>]*>\\s*${escaped}\\s*<\\/t[hd]>\\s*<td[^>]*>([\\s\\S]*?)<\\/td>[\\s\\S]*?<\\/tr>`, "i"),
new RegExp(`${escaped}\\s*<\\/t[hd]>\\s*<td[^>]*>([\\s\\S]*?)<\\/td>`, "i")
]
for (const pattern of patterns) {
const match = source.match(pattern)
if (match) return cleanText(htmlToText(match[1]))
}
return null
}
function textBetweenLabels(html, label) {
const source = String(html || "")
const escaped = escapeRegExp(label)
const pattern = new RegExp(`${escaped}\\s*<\\/[^>]+>\\s*<[^>]+>([\\s\\S]*?)<\\/[^>]+>`, "i")
const match = source.match(pattern)
return match ? cleanText(htmlToText(match[1])) : null
}
function parseRegistrationPeriod(text) {
const plain = cleanText(text)
const match = plain.match(/(\d{4}[./-]\d{1,2}[./-]\d{1,2})(?:\s*\d{1,2}:\d{2})?\s*[~-]\s*(\d{4}[./-]\d{1,2}[./-]\d{1,2})(?:\s*\d{1,2}:\d{2})?/)
if (!match) return { start: null, end: null }
return { start: normalizeDate(match[1]), end: normalizeDate(match[2]) }
}
function parseFirstDateAfterTitle(text, title) {
const plain = cleanText(text)
const idx = title ? plain.indexOf(cleanText(title)) : -1
const tail = idx >= 0 ? plain.slice(idx + cleanText(title).length) : plain
return normalizeDate(tail)
}
function parseFirstIsoDate(text) {
return normalizeDate(text)
}
function parseDeadline(text, eventDate) {
const plain = cleanText(text)
const match = plain.match(/(?:접수\s*)?마감[:\s]*(\d{1,2})월\s*(\d{1,2})일/)
if (!match) return null
const year = eventDate ? Number(eventDate.slice(0, 4)) : new Date().getFullYear()
return `${year}-${match[1].padStart(2, "0")}-${match[2].padStart(2, "0")}`
}
function normalizeDate(value) {
const text = cleanText(value || "")
const match = text.match(/(\d{4})[./-](\d{1,2})[./-](\d{1,2})/)
if (!match) return null
return `${match[1]}-${match[2].padStart(2, "0")}-${match[3].padStart(2, "0")}`
}
function findLocationLine(text) {
const plain = cleanText(text)
const match = plain.match(/\d{4}[./-]\d{1,2}[./-]\d{1,2}[^가-힣]*(서울|부산|대구|인천|광주|대전|울산|세종|경기|강원|충북|충남|전북|전남|경북|경남|제주)\s+([^접등웹주정]+)/)
if (match) return cleanText(`${match[1]} ${match[2]}`)
return null
}
function stripRegion(locationLine, region) {
if (!locationLine || !region) return locationLine
return cleanText(String(locationLine).replace(new RegExp(`^${escapeRegExp(region)}\\s*`), ""))
}
function inferRegion(locations, fallbackText) {
const candidates = ["서울", "부산", "대구", "인천", "광주", "대전", "울산", "세종", "경기", "강원", "충북", "충남", "전북", "전남", "경북", "경남", "제주"]
for (const location of Array.isArray(locations) ? locations : [locations]) {
const locationText = cleanText(location || "")
const firstTokenRegion = normalizeRegion(String(locationText).split(/\s+/)[0])
const locationRegion = (candidates.includes(firstTokenRegion) ? firstTokenRegion : null) || candidates.find((candidate) => locationText.includes(candidate))
if (locationRegion) return locationRegion
}
const fallbackHaystack = cleanText(fallbackText || "")
return candidates.find((candidate) => fallbackHaystack.includes(candidate)) || null
}
function normalizeRegion(region) {
const value = cleanText(region || "")
const map = {
서울특별시: "서울",
부산광역시: "부산",
대구광역시: "대구",
인천광역시: "인천",
광주광역시: "광주",
대전광역시: "대전",
울산광역시: "울산",
세종특별자치시: "세종",
경기도: "경기",
강원도: "강원",
충청북도: "충북",
충청남도: "충남",
전라북도: "전북",
전라남도: "전남",
경상북도: "경북",
경상남도: "경남",
제주특별자치도: "제주"
}
return map[value] || value || null
}
function detectStatus(text) {
const plain = cleanText(text)
if (/등록중|접수중|참가 신청 가능/.test(plain)) return plain.includes("접수중") ? "접수중" : "등록중"
if (/마감|등록마감|접수마감/.test(plain)) return "마감"
return null
}
function extractGorunningCategories(text, title) {
const plain = cleanText(text)
const cleanTitle = cleanText(title || "")
if (cleanTitle) {
const escaped = escapeRegExp(cleanTitle)
const pipeMatch = plain.match(new RegExp(`${escaped}\\s*\\|\\s*([^|]{1,120}?)\\s*\\|\\s*\\d{4}[./-]\\d{1,2}[./-]\\d{1,2}`, "i"))
if (pipeMatch) return extractRaceCategories(pipeMatch[1])
const idx = plain.indexOf(cleanTitle)
if (idx >= 0) {
const tail = plain.slice(idx + cleanTitle.length, idx + cleanTitle.length + 300)
const dateIdx = tail.search(/\d{4}[./-]\d{1,2}[./-]\d{1,2}/)
return extractRaceCategories(dateIdx >= 0 ? tail.slice(0, dateIdx) : tail)
}
}
return extractRaceCategories(plain.slice(0, 500))
}
function extractRaceCategories(text) {
const plain = cleanText(text)
const categories = []
const patterns = [
[/풀(?:코스)?|Full/gi, "Full"],
[/하프|Half/gi, "Half"],
[/\b10\s?km\b/gi, "10km"],
[/\b5\s?km\b/gi, "5km"],
[/\b3\s?km\s*걷기/gi, "3km 걷기"],
[/\b3\s?km\s*걷기\(어린이\)/gi, "3km 걷기(어린이)"]
]
for (const [pattern, label] of patterns) {
if (pattern.test(plain) && !categories.includes(label)) categories.push(label)
}
return categories
}
function splitCategories(text) {
return cleanText(text || "")
.split(/[,/·|]/)
.map((item) => cleanText(item))
.filter(Boolean)
}
function textAfterInlineLabel(text, label) {
const plain = cleanText(text)
const match = plain.match(new RegExp(`${escapeRegExp(label)}\\s*[:]\\s*([^\\n]+?)(?:\\s{2,}|$)`))
return match ? cleanText(match[1]) : null
}
function findOfficialUrl(html, fallbackUrl) {
const source = String(html || "")
const websiteBlock = source.match(/웹사이트[\s\S]{0,500}?<a\b[^>]*href=["'](https?:\/\/[^"']+)["'][^>]*>/i)
if (websiteBlock) return decodeHtml(websiteBlock[1])
const links = [...source.matchAll(/<a\b[^>]*href=["'](https?:\/\/[^"']+)["'][^>]*>/gi)].map((m) => decodeHtml(m[1]))
return links.find((link) => !link.includes("gorunning.kr") && !link.includes("map.naver.com")) || links.find((link) => !link.includes("gorunning.kr")) || fallbackUrl
}
function compactEvent(event) {
return {
source: event.source,
type: event.type,
title: event.title || null,
eventDate: event.eventDate || null,
region: event.region || null,
venue: event.venue || null,
registrationDeadline: event.registrationDeadline || null,
registrationPeriod: event.registrationPeriod || { start: null, end: null },
status: event.status || null,
categories: event.categories || [],
organizer: event.organizer || null,
officialUrl: event.officialUrl || null,
url: event.url || null
}
}
function htmlToText(html) {
return decodeHtml(String(html || "")
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>|<\/div>|<\/tr>|<\/h[1-6]>/gi, "\n")
.replace(/<[^>]+>/g, " "))
}
function cleanText(value) {
return decodeHtml(String(value || ""))
.replace(/\u00a0/g, " ")
.replace(/[ \t\r\f\v]+/g, " ")
.replace(/\n\s+/g, "\n")
.trim()
}
function decodeHtml(value) {
return String(value || "")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
module.exports = {
searchEvents,
parseGorunningList,
parseGorunningDetail,
parseTriathlonList,
parseTriathlonDetail,
GORUNNING_RACES_URL,
TRIATHLON_TOUR_URL
}

View file

@ -0,0 +1,463 @@
const test = require("node:test")
const assert = require("node:assert/strict")
const { spawnSync } = require("node:child_process")
const {
parseGorunningList,
parseGorunningDetail,
parseTriathlonList,
parseTriathlonDetail,
searchEvents
} = require("../src/index")
const gorunningListHtml = `<!doctype html><html><body>
<h3> 09 12 () 4 대회</h3>
<a href="/races/1070/2nd-chorokwooson-runway-marathon/">제2회 초록우산 런웨이 마라톤</a>
<a href="https://gorunning.kr/races/1071/white-run/">제2회 화이트런 생리대 기부마라톤</a>
<a href="/blog/not-a-race/">블로그</a>
</body></html>`
const gorunningDetailHtml = `<!doctype html><html><body>
<h1>제2회 초록우산 런웨이 마라톤</h1>
<p>하프 10km 5km 3km 걷기 3km 걷기(어린이)</p>
<p>2026/09/12 () 08:00 D-127</p>
<p>대전 대전엑스포시민광장</p>
<p>지금 참가 신청 가능</p>
<p>접수 마감: 8 1 (D-86) · 공식 사이트에서 참가비·정원 확인</p>
<h2>대회 정보</h2>
<p>주최자</p><p> </p>
<p>등록 기간</p><p>2026/04/13 ~ 2026/08/01 D-86</p>
<p>웹사이트</p><a href="https://mara1080.com/event/abc">https://mara1080.com/event/abc</a>
<p>주소</p><p></p>
<p>정보 검증</p><p>2026 4 14 </p>
</body></html>`
const triathlonListHtml = `<!doctype html><html><body>
<table><tbody>
<tr><td>대회정보</td><td></td><td>/</td></tr>
<tr>
<td>접수중 <a href="/events/tour/overview/?mode=overview&tourcd=2085">2026 고령군수배 대가야 전국 철인3종 대회</a> : : ()</td>
<td>2026.06.21</td><td></td>
</tr>
</tbody></table>
</body></html>`
const triathlonDetailHtml = `<!doctype html><html><body>
<h2>2026 고령군수배 대가야 전국 철인3종 대회</h2>
<table>
<tr><th>대회명</th><td>2026 3 </td></tr>
<tr><th>대회기간</th><td>2026-06-21</td></tr>
<tr><th>대회장소</th><td> </td></tr>
<tr><th>주최</th><td></td></tr>
<tr><th>접수기간</th><td>2026-04-27 14:00 ~ 2026-05-10 18:00</td></tr>
</table>
<p>코스: 생활체육(스탠다드), 릴레이</p>
</body></html>`
test("parseGorunningList extracts unique race detail URLs", () => {
assert.deepEqual(parseGorunningList(gorunningListHtml), [
"https://gorunning.kr/races/1070/2nd-chorokwooson-runway-marathon/",
"https://gorunning.kr/races/1071/white-run/"
])
})
test("parseGorunningList ignores off-origin race detail links", () => {
const html = `<!doctype html><html><body>
<a href="https://evil.example/races/123/fake/">악성 외부 링크</a>
<a href="/races/1070/2nd-chorokwooson-runway-marathon/">정상 대회</a>
</body></html>`
assert.deepEqual(parseGorunningList(html), [
"https://gorunning.kr/races/1070/2nd-chorokwooson-runway-marathon/"
])
})
test("parseTriathlonList ignores off-origin federation detail links", () => {
const html = `<!doctype html><html><body>
<a href="https://evil.example/events/tour/overview/?mode=overview&tourcd=9999">외부 철인3종 링크</a>
<a href="/events/tour/overview/?mode=overview&tourcd=2085">정상 철인3종 대회</a>
</body></html>`
assert.deepEqual(parseTriathlonList(html), [
{
url: "https://triathlon.or.kr/events/tour/overview/?mode=overview&tourcd=2085",
categories: []
}
])
})
test("parseTriathlonList filters education and admin entries before detail fetch", () => {
const html = `<!doctype html><html><body><table><tbody>
<tr><td><a href="/events/tour/overview/?mode=overview&tourcd=3001">2026 철인3종 2 대회규정 정기 교육</a> : </td></tr>
<tr><td><a href="/events/tour/overview/?mode=overview&tourcd=2085">2026 고령군수배 대가야 전국 철인3종 대회</a> : : ()</td></tr>
</tbody></table></body></html>`
assert.deepEqual(parseTriathlonList(html), [
{
url: "https://triathlon.or.kr/events/tour/overview/?mode=overview&tourcd=2085",
categories: ["생활체육(스탠다드)"]
}
])
})
test("searchEvents continues past pre-filter windows until GoRunning matches are collected", async () => {
const links = Array.from({ length: 31 }, (_, index) => {
const id = 2000 + index
return `<a href="/races/${id}/race-${index + 1}/">대회 ${index + 1}</a>`
}).join("\n")
const fetcher = async (url) => {
const textUrl = String(url)
if (textUrl === "https://gorunning.kr/races/") return htmlResponse(`<!doctype html><html><body>${links}</body></html>`)
const id = Number(textUrl.match(/\/races\/(\d+)\//)?.[1])
const isJeju = id === 2030
return htmlResponse(`<!doctype html><html><body>
<h1>${isJeju ? "제주 바다 마라톤" : `서울 준비 대회 ${id}`}</h1>
<p>10km</p>
<p>2026/05/10 () 08:00</p>
<p>${isJeju ? "제주 월드컵경기장" : "서울 광장"}</p>
<p>주소</p><p>${isJeju ? " " : " "}</p>
</body></html>`)
}
const result = await searchEvents({
query: "제주",
from: "2026-01-01",
to: "2026-12-31",
limit: 10,
fetcher
})
assert.equal(result.items.length, 1)
assert.equal(result.items[0].title, "제주 바다 마라톤")
assert.deepEqual(result.warnings, [])
})
test("searchEvents continues past pre-filter windows until triathlon matches are collected", async () => {
const links = Array.from({ length: 21 }, (_, index) => {
const tourcd = 4000 + index
const title = index === 20 ? "2026 제주 국제 철인3종 대회" : `2026 서울 철인3종 대회 ${index + 1}`
return `<tr><td><a href="/events/tour/overview/?mode=overview&tourcd=${tourcd}">${title}</a> 장소: ${index === 20 ? "제주 서귀포" : "서울 한강"} 코스: 스탠다드</td></tr>`
}).join("\n")
const fetcher = async (url) => {
const textUrl = String(url)
if (textUrl === "https://gorunning.kr/races/") return htmlResponse("")
if (textUrl.startsWith("https://triathlon.or.kr/events/tour/") && !textUrl.includes("overview")) {
return htmlResponse(`<!doctype html><html><body><table>${links}</table></body></html>`)
}
const tourcd = Number(new URL(textUrl).searchParams.get("tourcd"))
const isJeju = tourcd === 4020
return htmlResponse(`<!doctype html><html><body>
<h2>${isJeju ? "2026 제주 국제 철인3종 대회" : `2026 서울 철인3종 대회 ${tourcd}`}</h2>
<table>
<tr><th>대회명</th><td>${isJeju ? "2026 3 " : `2026 3 ${tourcd}`}</td></tr>
<tr><th>대회기간</th><td>2026-07-01</td></tr>
<tr><th>대회장소</th><td>${isJeju ? " " : " "}</td></tr>
<tr><th>접수기간</th><td>2026-05-01 ~ 2026-06-01</td></tr>
</table>
</body></html>`)
}
const result = await searchEvents({
query: "제주",
from: "2026-01-01",
to: "2026-12-31",
includeTriathlon: true,
limit: 10,
fetcher
})
assert.equal(result.items.length, 1)
assert.equal(result.items[0].title, "2026 제주 국제 철인3종 대회")
assert.deepEqual(result.warnings, [])
})
test("searchEvents warns when a configurable detail budget is exhausted before source list end", async () => {
const links = Array.from({ length: 6 }, (_, index) => {
const id = 5000 + index
return `<a href="/races/${id}/race-${index + 1}/">대회 ${index + 1}</a>`
}).join("\n")
const fetcher = async (url) => {
const textUrl = String(url)
if (textUrl === "https://gorunning.kr/races/") return htmlResponse(`<!doctype html><html><body>${links}</body></html>`)
return htmlResponse(`<!doctype html><html><body>
<h1>서울 준비 대회</h1>
<p>2026/05/10 () 08:00</p>
<p>서울 광장</p>
<p>주소</p><p> </p>
</body></html>`)
}
const result = await searchEvents({
query: "제주",
from: "2026-01-01",
to: "2026-12-31",
limit: 10,
maxDetailsPerSource: 3,
fetcher
})
assert.equal(result.items.length, 0)
assert.match(result.warnings.join("\n"), /gorunning detail budget exhausted after 3 of 6 source links/)
})
test("searchEvents applies one triathlon detail budget across selected years", async () => {
const seenDetails = []
const fetcher = async (url) => {
const textUrl = String(url)
if (textUrl === "https://gorunning.kr/races/") return htmlResponse("")
if (textUrl === "https://triathlon.or.kr/events/tour/?sYear=2026&vType=list") {
return htmlResponse(`<!doctype html><html><body><table>
<tr><td><a href="/events/tour/overview/?mode=overview&tourcd=6101">2026 서울 철인3종 대회</a> : : </td></tr>
</table></body></html>`)
}
if (textUrl === "https://triathlon.or.kr/events/tour/?sYear=2027&vType=list") {
return htmlResponse(`<!doctype html><html><body><table>
<tr><td><a href="/events/tour/overview/?mode=overview&tourcd=7101">2027 제주 철인3종 대회</a> : : </td></tr>
</table></body></html>`)
}
if (textUrl.includes("/events/tour/overview/")) {
seenDetails.push(textUrl)
const tourcd = new URL(textUrl).searchParams.get("tourcd")
return htmlResponse(`<!doctype html><html><body>
<h2>${tourcd} 철인3종 대회</h2>
<table>
<tr><th>대회명</th><td>${tourcd} 3 </td></tr>
<tr><th>대회기간</th><td>${tourcd === "6101" ? "2026" : "2027"}-07-01</td></tr>
<tr><th>대회장소</th><td> </td></tr>
</table>
</body></html>`)
}
return new Response("not found", { status: 404 })
}
const result = await searchEvents({
query: "부산",
from: "2026-01-01",
to: "2027-12-31",
includeTriathlon: true,
limit: 5,
maxDetailsPerSource: 1,
fetcher
})
assert.equal(result.items.length, 0)
assert.equal(seenDetails.length, 1)
assert.deepEqual(seenDetails, [
"https://triathlon.or.kr/events/tour/overview/?mode=overview&tourcd=6101"
])
assert.match(result.warnings.join("\n"), /triathlon detail budget exhausted after 1 of 2 source links/)
})
test("CLI help documents max-details-per-source budget option", () => {
const result = spawnSync(process.execPath, ["src/cli.js", "--help"], {
cwd: __dirname + "/..",
encoding: "utf8"
})
assert.equal(result.status, 0)
assert.match(result.stdout, /--max-details-per-source <number>/)
})
test("CLI maps max-details-per-source argument to search options", () => {
const { parseArgs } = require("../src/cli")
const options = parseArgs([
"고령",
"--from",
"2026-01-01",
"--include-triathlon",
"--max-details-per-source",
"7"
])
assert.equal(options.maxDetailsPerSource, 7)
})
test("parseTriathlonList keeps race rows isolated from neighboring education rows", () => {
const html = `<!doctype html><html><body><table><tbody>
<tr><td><a href="/events/tour/overview/?mode=overview&tourcd=3001">2026 철인3종 2 대회규정 정기 교육</a> : </td></tr>
<tr><td><a href="/events/tour/overview/?mode=overview&tourcd=2085">2026 고령군수배 대가야 전국 철인3종 대회</a> : : ()</td></tr>
<tr><td>교육 신청 안내</td></tr>
</tbody></table></body></html>`
assert.deepEqual(parseTriathlonList(html), [
{
url: "https://triathlon.or.kr/events/tour/overview/?mode=overview&tourcd=2085",
categories: ["생활체육(스탠다드)"]
}
])
})
test("parseTriathlonList extracts categories from each race row without neighboring leakage", () => {
const html = `<!doctype html><html><body><table><tbody>
<tr><td><a href="/events/tour/overview/?mode=overview&tourcd=2084">2026 부산 철인3종 대회</a> : : </td></tr>
<tr><td><a href="/events/tour/overview/?mode=overview&tourcd=2085">2026 제주 철인3종 대회</a> : : </td></tr>
</tbody></table></body></html>`
assert.deepEqual(parseTriathlonList(html), [
{
url: "https://triathlon.or.kr/events/tour/overview/?mode=overview&tourcd=2084",
categories: ["스프린트"]
},
{
url: "https://triathlon.or.kr/events/tour/overview/?mode=overview&tourcd=2085",
categories: ["올림픽"]
}
])
})
test("parseGorunningDetail normalizes venue, deadline, and categories", () => {
const event = parseGorunningDetail(gorunningDetailHtml, "https://gorunning.kr/races/1070/2nd-chorokwooson-runway-marathon/")
assert.equal(event.source, "gorunning")
assert.equal(event.type, "marathon")
assert.equal(event.title, "제2회 초록우산 런웨이 마라톤")
assert.equal(event.eventDate, "2026-09-12")
assert.equal(event.region, "대전")
assert.equal(event.venue, "대전엑스포시민광장")
assert.equal(event.registrationDeadline, "2026-08-01")
assert.equal(event.registrationPeriod.start, "2026-04-13")
assert.equal(event.registrationPeriod.end, "2026-08-01")
assert.equal(event.status, "등록중")
assert.deepEqual(event.categories, ["Half", "10km", "5km", "3km 걷기", "3km 걷기(어린이)"])
assert.equal(event.organizer, "초록우산 대전세종지역본부")
assert.equal(event.officialUrl, "https://mara1080.com/event/abc")
})
test("parseGorunningDetail infers region from event location before unrelated page text", () => {
const yonginHtml = `<!doctype html><html><body>
<nav>서울 인기 마라톤 바로가기</nav>
<h1>2026 용인마라톤</h1>
<p>10km 5km</p>
<p>2026/06/06 () 08:00 D-30</p>
<p>경기도 용인특례시청 잔디광장</p>
<p>등록 기간</p><p>2026/04/01 ~ 2026/05/15 </p>
<p>주소</p><p> </p>
</body></html>`
const event = parseGorunningDetail(yonginHtml, "https://gorunning.kr/races/9999/yongin-marathon/")
assert.equal(event.region, "경기")
assert.equal(event.venue, "경기도 용인특례시청 잔디광장")
})
test("parseTriathlonList extracts official federation detail URLs with list categories", () => {
assert.deepEqual(parseTriathlonList(triathlonListHtml), [
{
url: "https://triathlon.or.kr/events/tour/overview/?mode=overview&tourcd=2085",
categories: ["생활체육(스탠다드)"]
}
])
})
test("parseTriathlonDetail normalizes course and registration deadline", () => {
const event = parseTriathlonDetail(triathlonDetailHtml, "https://triathlon.or.kr/events/tour/overview/?mode=overview&tourcd=2085")
assert.equal(event.source, "triathlon.or.kr")
assert.equal(event.type, "triathlon")
assert.equal(event.title, "2026 고령군수배 대가야 전국 철인3종 대회")
assert.equal(event.eventDate, "2026-06-21")
assert.equal(event.region, "경북")
assert.equal(event.venue, "경북 고령군 대가야생활촌 일원")
assert.equal(event.registrationDeadline, "2026-05-10")
assert.equal(event.registrationPeriod.start, "2026-04-27")
assert.equal(event.registrationPeriod.end, "2026-05-10")
assert.deepEqual(event.categories, ["생활체육(스탠다드)", "릴레이"])
assert.equal(event.organizer, "고령군체육회")
})
test("searchEvents fetches marathon and optional triathlon details with filters", async () => {
const seen = []
const fetcher = async (url) => {
seen.push(String(url))
if (String(url) === "https://gorunning.kr/races/") return htmlResponse(gorunningListHtml)
if (String(url).includes("1070")) return htmlResponse(gorunningDetailHtml)
if (String(url).includes("1071")) return htmlResponse(gorunningDetailHtml.replaceAll("초록우산", "화이트런").replaceAll("대전", "서울").replaceAll("대전엑스포시민광장", "서울광장"))
if (String(url).startsWith("https://triathlon.or.kr/events/tour/")) {
if (String(url).includes("overview")) return htmlResponse(triathlonDetailHtml)
return htmlResponse(triathlonListHtml)
}
return new Response("not found", { status: 404 })
}
const result = await searchEvents({
query: "대전",
from: "2026-06-01",
to: "2026-12-31",
includeTriathlon: true,
limit: 5,
fetcher
})
assert.equal(result.query, "대전")
assert.deepEqual(result.warnings, [])
assert.equal(result.items.length, 1)
assert.equal(result.items[0].title, "제2회 초록우산 런웨이 마라톤")
assert.equal(result.items[0].registrationDeadline, "2026-08-01")
assert.ok(seen.includes("https://gorunning.kr/races/"))
assert.ok(seen.includes("https://triathlon.or.kr/events/tour/?sYear=2026&vType=list"))
})
test("searchEvents preserves triathlon list categories when detail omits course text", async () => {
const fetcher = async (url) => {
if (String(url) === "https://gorunning.kr/races/") return htmlResponse("")
if (String(url).startsWith("https://triathlon.or.kr/events/tour/")) {
if (String(url).includes("overview")) {
return htmlResponse(triathlonDetailHtml.replace("<p>코스: 생활체육(스탠다드), 릴레이</p>", ""))
}
return htmlResponse(triathlonListHtml)
}
return new Response("not found", { status: 404 })
}
const result = await searchEvents({
query: "고령",
from: "2026-01-01",
to: "2026-12-31",
includeTriathlon: true,
fetcher
})
assert.equal(result.items.length, 1)
assert.deepEqual(result.items[0].categories, ["생활체육(스탠다드)"])
})
test("searchEvents returns successful marathon results with warnings when triathlon source fails", async () => {
const fetcher = async (url) => {
if (String(url) === "https://gorunning.kr/races/") return htmlResponse(gorunningListHtml)
if (String(url).includes("1070")) return htmlResponse(gorunningDetailHtml)
if (String(url).includes("1071")) return new Response("temporary upstream failure", { status: 503 })
if (String(url).startsWith("https://triathlon.or.kr/events/tour/")) {
return new Response("triathlon unavailable", { status: 502 })
}
return new Response("not found", { status: 404 })
}
const result = await searchEvents({
query: "대전",
from: "2026-06-01",
to: "2026-12-31",
includeTriathlon: true,
fetcher
})
assert.equal(result.items.length, 1)
assert.equal(result.items[0].title, "제2회 초록우산 런웨이 마라톤")
assert.match(result.warnings.join("\n"), /gorunning detail failed/)
assert.match(result.warnings.join("\n"), /triathlon source failed/)
})
function htmlResponse(html) {
return new Response(html, {
status: 200,
headers: {
"content-type": "text/html; charset=utf-8"
}
})
}

View file

@ -1,169 +0,0 @@
---
name: sh-notice-search
description: Search official SH 서울주택도시개발공사 공고/공지 lists and details through k-skill-proxy. Use when a user asks about SH, 서울주택도시공사/서울주택도시개발공사, i-sh, 서울 행복주택/임대/공공원룸/장기전세 공고 or 당첨자 발표.
license: MIT
metadata:
category: real-estate
locale: ko-KR
phase: v1
---
# SH 청약·주택 공고문 조회
## What this skill does
서울주택도시개발공사(SH, `i-sh.co.kr`)의 공식 **공고 및 공지** 게시판을 조회한다. 요청은 `k-skill-proxy``/v1/sh-notice/*` 라우트로 보내고, 결과는 목록·상세·첨부 미리보기 링크로 정리한다.
SH 사이트는 LH처럼 공공데이터포털 전용 공고 API가 안정적으로 열려 있지 않아, 프록시가 공식 SH HTML 게시판을 읽고 정규화한다. 본 스킬은 read-only 조회만 한다.
## When to use
- "SH 행복주택 공고 올라온 거 있어?"
- "서울주택도시공사 장기전세 공고 찾아줘"
- "i-sh 공공원룸 당첨자 발표 확인해줘"
- "SH 공고 303994 상세 보여줘"
- "서울 공공임대 공고 최근 것 정리해줘"
## When not to use
- LH 공고 전용 조회 → `lh-notice-search` 사용
- GH·iH 등 다른 지방공사 공고
- 청약 신청 자동화/제출, 로그인 필요한 마이페이지 업무
- 개별 자격 심사, 당첨 예측, 가점 계산
## Inputs
목록 조회:
- `q` / `keyword` / `srchWord`: 검색어. 예: `행복주택`, `장기전세`, `공공원룸`, `당첨자`. 최대 100자.
- `srchTp` / `searchType`: `title`/`제목` 또는 `content`/`내용`. 검색어가 있고 비워두면 자동으로 제목 검색(`title`)으로 처리한다. SH 게시판은 `srchTp` 없이 `srchWord`만 보내면 키워드를 무시하고 전체 목록을 돌려주기 때문이다.
- `page`: 페이지 번호. 기본 `1`.
- `pageSize`: 반환 개수. 기본 `10`, 최대 `10`. SH 게시판이 한 번 호출에 최대 10건만 내려주기 때문에, 더 많은 결과를 보려면 `page` 를 증가시킨다.
- `multiItmSeq`: SH 게시판 분류(숫자). 기본 `2`(공고 및 공지).
상세 조회:
- `seq`: 목록 응답의 `seq` 값. 숫자 필수.
- `multiItmSeq`: 기본 `2`.
## Default path
`KSKILL_PROXY_BASE_URL` 환경변수가 있으면 그 값, 없으면 기본 `https://k-skill-proxy.nomadamas.org` 를 사용한다.
```bash
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
BASE="${BASE%/}"
```
## Supported endpoints
### 공고 목록 조회
```http
GET /v1/sh-notice/search
```
예시:
```bash
curl -fsS --get "${BASE}/v1/sh-notice/search" \
--data-urlencode 'q=행복주택' \
--data-urlencode 'srchTp=title' \
--data-urlencode 'pageSize=10'
```
필터 없이 호출하면 SH 공고/공지 최신 목록을 돌려준다.
### 공고 상세 조회
```http
GET /v1/sh-notice/detail?seq={게시글번호}
```
예시:
```bash
curl -fsS --get "${BASE}/v1/sh-notice/detail" \
--data-urlencode 'seq=303994'
```
## Response shape
### 목록 응답
```json
{
"items": [
{
"seq": "304022",
"number": "1606",
"title": "전산작업에 따른 서비스(신한인증서) 이용 안내",
"department": "시스템운영부",
"registered_date": "2026-05-08",
"views": 97,
"is_new": true,
"detail_url": "https://www.i-sh.co.kr/app/lay2/program/S48T1581C563/www/brd/m_247/view.do?multi_itm_seq=2&seq=304022"
}
],
"summary": {
"page": 1,
"page_size": 10,
"returned_count": 10,
"total_count": 1606
},
"query": {
"srch_word": "행복주택",
"srch_tp": "1",
"multi_itm_seq": "2"
},
"proxy": {
"name": "k-skill-proxy",
"cache": { "hit": false, "ttl_ms": 300000 }
}
}
```
### 상세 응답
```json
{
"notice": {
"seq": "303994",
"title": "행복주택 예비당첨자 게시",
"registered_date": "2026-05-07",
"views": 1972,
"content_text": "2022년 2차 행복주택 예비17차 ...",
"attachments": [
{
"filename": "2022년 2차 행복주택 예비 17차 당첨자명단.pdf",
"file_seq": "1",
"preview_url": "https://www.i-sh.co.kr/app/com/util/htmlConverter.do?..."
}
],
"detail_url": "https://www.i-sh.co.kr/app/lay2/program/S48T1581C563/www/brd/m_247/view.do?multi_itm_seq=2&seq=303994"
},
"query": { "seq": "303994", "multi_itm_seq": "2" },
"proxy": { "...": "..." }
}
```
## Response policy
- 공식 SH 사이트(`www.i-sh.co.kr`) 정보만 사용한다.
- 목록 결과는 상위 3~5건만 간결히 보여주고, 제목·담당부서·등록일·조회수·공식 링크를 포함한다.
- 상세 조회에서는 본문 요약과 첨부파일명을 우선 보여준다. 첨부 원문 확인이 중요하면 `preview_url` 을 함께 제시한다.
- 마감일이 별도 필드로 제공되지 않는 게시판 구조다. 본문/첨부 공고문에 있는 접수기간은 상세 본문을 읽고 별도 추출·요약해야 한다.
- SH 공고는 LH 공고와 ID 체계가 다르다. `seq` 는 SH 게시글 번호이며 LH `pan_id` 가 아니다.
## Failure modes
- `seq` 가 없거나 숫자가 아니면 `400 bad_request`. `multiItmSeq` 도 숫자만 허용된다.
- 검색어가 100자를 넘으면 `400 bad_request`.
- SH 사이트가 일시 장애이거나 HTML 구조가 바뀌면 `502 upstream_error` 또는 빈 결과가 내려올 수 있다.
- 첨부 원문 확인에는 공식 미리보기 링크(`preview_url`)를 우선 사용한다. 직접 다운로드 URL은 SH 사이트 흐름이 바뀔 수 있어 제공하지 않는다.
## Done when
- 사용자의 키워드/공고 유형 의도에 맞춰 `/v1/sh-notice/search` 를 호출했다.
- 결과에 제목, 담당부서, 등록일, 공식 상세 링크가 포함되어 있다.
- 상세가 필요하면 목록의 `seq``/v1/sh-notice/detail` 을 호출해 본문과 첨부파일을 확인했다.