mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Keep Danawa price search mergeable and consistent
Merge the current dev branch so PR #226 no longer conflicts with the SH notice removal and marathon-schedule additions, while keeping the Danawa helper/documentation aligned with its 실구매가 contract. The PR diff is narrowed to Danawa files, README discovery, and the root lint hook for the new helper. Constraint: PR #226 targets dev and GitHub reported DIRTY before this worker fix. Rejected: Leaving inherited court-auction release metadata and SH-notice whitespace in the PR | they were unrelated to the Danawa skill and disappeared after reconciling with current dev. Confidence: high Scope-risk: narrow Directive: Keep Danawa examples and JSON field docs synchronized with danawa_search.py CLI/output names. Tested: python3 -m py_compile danawa-price-search/scripts/danawa_search.py; python3 danawa-price-search/scripts/danawa_search.py --help; python3 danawa-price-search/scripts/danawa_search.py search '에어팟 프로 2세대' --limit 1; npm run ci; git diff --check Not-tested: Live Danawa offers endpoint after commit beyond search smoke.
This commit is contained in:
commit
5680497cb3
24 changed files with 1263 additions and 868 deletions
5
.changeset/korean-marathon-schedule.md
Normal file
5
.changeset/korean-marathon-schedule.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"korean-marathon-schedule": minor
|
||||
---
|
||||
|
||||
Add a Korean marathon and triathlon schedule lookup skill backed by public event pages.
|
||||
|
|
@ -12,13 +12,6 @@
|
|||
- 발견한 검색 URL, 필수 입력값, 결과 해석 규칙, fallback 순서, 실패 모드는 `SKILL.md`와 helper 코드에 명확히 남긴다. 자세한 체크리스트는 `docs/adding-a-skill.md`를 따른다.
|
||||
- 새 크롤링 dependency는 기본값으로 추가하지 말고 기존 기능, 공개 endpoint, 좁은 proxy route로 해결 가능한지 먼저 확인한다.
|
||||
|
||||
## Crawling/search skill authoring
|
||||
|
||||
- 크롤링/검색 k-skill의 목표는 최종적으로 대상 사이트에 맞는 site-dependent 접근 방법을 스킬에 패키징하는 것이다.
|
||||
- 다만 방법을 고정하기 전에 `insane-search`식 site-agnostic discovery를 먼저 수행한다: 공개 입구, 브라우저에서 보이는 데이터 흐름, RSS/sitemap/정적 JSON/모바일 페이지, 차단·빈 응답·로그인벽 실패 모드를 확인한다.
|
||||
- 발견한 검색 URL, 필수 입력값, 결과 해석 규칙, fallback 순서, 실패 모드는 `SKILL.md`와 helper 코드에 명확히 남긴다. 자세한 체크리스트는 `docs/adding-a-skill.md`를 따른다.
|
||||
- 새 크롤링 dependency는 기본값으로 추가하지 말고 기존 기능, 공개 endpoint, 좁은 proxy route로 해결 가능한지 먼저 확인한다.
|
||||
|
||||
## Proxy server development
|
||||
|
||||
- 개발 repo: `/Users/jeffrey/Projects/k-skill` (이 디렉토리, `dev` 브랜치)
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
@ -135,7 +135,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)
|
||||
|
|
@ -151,6 +150,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)
|
||||
|
|
|
|||
|
|
@ -225,6 +225,7 @@ def offers(pcode: str, limit: int = 20, include_shipping: bool = False) -> Dict[
|
|||
)
|
||||
if len(rows) >= limit:
|
||||
break
|
||||
rows.sort(key=lambda row: (row["total_price"] is None, row["total_price"] or row["price"]))
|
||||
return {"pcode": str(pcode), "title": meta.get("sProductFullName"), "source_url": meta["source_url"], "count": len(rows), "offers": rows, "meta": {"extraction": "danawa-price-ajax", "include_shipping": include_shipping, "ts": int(time.time())}}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
```bash
|
||||
python3 danawa-price-search/scripts/danawa_search.py search "맥북 에어 M4" --limit 5
|
||||
python3 danawa-price-search/scripts/danawa_search.py offers 28208783 --limit 10
|
||||
python3 danawa-price-search/scripts/danawa_search.py compare "갤럭시 S25" --products 3 --offers 5
|
||||
python3 danawa-price-search/scripts/danawa_search.py compare "갤럭시 S25" --limit 3 --offers 5
|
||||
```
|
||||
|
||||
## 출력 해석
|
||||
|
|
@ -36,7 +36,7 @@ python3 danawa-price-search/scripts/danawa_search.py compare "갤럭시 S25" --p
|
|||
- `card_price`: 카드 적용 표시가
|
||||
- `card_discount`: 표시가와 카드가 차액
|
||||
- `installment`: 무이자 할부 문구
|
||||
- `link`: 다나와 경유 링크
|
||||
- `url`: 다나와 경유 링크
|
||||
|
||||
사용자에게는 `total_price` 기준으로 정렬한 Markdown 표를 먼저 보여주고, 카드가는 별도 열에 표시합니다.
|
||||
|
||||
|
|
|
|||
66
docs/features/korean-marathon-schedule.md
Normal file
66
docs/features/korean-marathon-schedule.md
Normal 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종협회 상세 링크를 대신 제공합니다.
|
||||
|
|
@ -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(인천) 등 다른 지방공사 공고
|
||||
- 청약 신청 자동화/제출, 로그인 필요한 마이페이지 업무
|
||||
- 개별 자격 심사, 당첨 예측, 가점 계산
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
121
korean-marathon-schedule/SKILL.md
Normal file
121
korean-marathon-schedule/SKILL.md
Normal 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.
|
||||
16
package-lock.json
generated
16
package-lock.json
generated
|
|
@ -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
|
||||
|
|
@ -1738,7 +1742,7 @@
|
|||
}
|
||||
},
|
||||
"packages/court-auction-notice-search": {
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"court-auction-notice-search": "bin/court-auction-notice-search.js"
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 danawa-price-search/scripts/danawa_search.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"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,5 @@
|
|||
# court-auction-notice-search
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d7aca1b: Fix sale notice search to post the court site month key (`YYYYMM`) and filter exact-day requests locally; normalize the current nested notice-detail response shape and HTML-formatted prices.
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "court-auction-notice-search",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.0",
|
||||
"description": "Korean court auction (대법원경매정보) real estate sale notice and case lookup with direct HTTP + Playwright fallback",
|
||||
"license": "MIT",
|
||||
"main": "src/index.js",
|
||||
|
|
|
|||
|
|
@ -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` 헤더를 서버 쪽에서만 주입합니다.
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /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
|
||||
};
|
||||
|
|
@ -1,150 +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&seq=303994&data_tp=A&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&seq=303994&data_tp=A&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);
|
||||
});
|
||||
35
packages/korean-marathon-schedule/README.md
Normal file
35
packages/korean-marathon-schedule/README.md
Normal 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`.
|
||||
35
packages/korean-marathon-schedule/package.json
Normal file
35
packages/korean-marathon-schedule/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
43
packages/korean-marathon-schedule/src/cli.js
Executable file
43
packages/korean-marathon-schedule/src/cli.js
Executable 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 }
|
||||
472
packages/korean-marathon-schedule/src/index.js
Normal file
472
packages/korean-marathon-schedule/src/index.js
Normal 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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
searchEvents,
|
||||
parseGorunningList,
|
||||
parseGorunningDetail,
|
||||
parseTriathlonList,
|
||||
parseTriathlonDetail,
|
||||
GORUNNING_RACES_URL,
|
||||
TRIATHLON_TOUR_URL
|
||||
}
|
||||
463
packages/korean-marathon-schedule/test/index.test.js
Normal file
463
packages/korean-marathon-schedule/test/index.test.js
Normal 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"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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` 을 호출해 본문과 첨부파일을 확인했다.
|
||||
Loading…
Add table
Add a link
Reference in a new issue