Add public marathon schedule lookup

Implement a read-only Korean marathon schedule skill so agents can report event dates, venues, registration deadlines, and categories from public race pages, with best-effort triathlon coverage.

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

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

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

Confidence: high

Scope-risk: moderate

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

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

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

Co-authored-by: OmX <omx@oh-my-codex.dev>
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-09 22:29:22 +09:00
commit 341a2b00d3
11 changed files with 915 additions and 1 deletions

View file

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

View file

@ -56,6 +56,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) |
@ -150,6 +151,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md)
- [근처 공중화장실 찾기 가이드](docs/features/public-restroom-nearby.md)
- [근처 공영주차장 찾기 가이드](docs/features/parking-lot-search.md)
- [한국 마라톤 일정 조회 가이드](docs/features/korean-marathon-schedule.md)
- [KBO 경기 결과 조회](docs/features/kbo-results.md)
- [KBL 경기 결과 가이드](docs/features/kbl-results.md)
- [K리그 경기 결과 조회](docs/features/kleague-results.md)

View file

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

View file

@ -0,0 +1,120 @@
---
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: 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: links matching `/events/tour/overview/?mode=overview&tourcd=<id>`
- detail fields used: title, event date, venue, registration period, organizer, and course/category labels.
- 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
})
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
```
### 2. Summarize conservatively
For each event, show:
```text
- 대회명: ...
일정: ...
장소: ...
신청 마감: ...
종목: ...
링크: ...
```
If no deadline is present, say `신청 마감일을 공개 페이지에서 확인하지 못함` instead of guessing.
### 3. Use fallback order
1. GoRunning list → GoRunning detail pages for marathon/road-running schedules.
2. If the user asks for triathlon or `includeTriathlon` is useful, query the 대한철인3종협회 year list and public detail pages.
3. If either source returns an empty, blocked, or changed page, report the source-specific failure 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.
- Some official event websites may be linked only from the detail page; if absent, return the source detail URL.
- Registration may already be closed even if the event date is upcoming.
- Login, payment, CAPTCHA, or private member-only pages are outside scope and must not be automated.
## Notes
- This is a read-only lookup skill.
- No k-skill-proxy route is used because the upstream surfaces are public and do not require API keys.
- Do not register, reserve, pay for, or modify race entries.

14
package-lock.json generated
View file

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

View file

@ -12,7 +12,7 @@
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py coupang-product-search/scripts/coupang_partners_mcp.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"typecheck": "tsc --noEmit",
"test": "node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts python3 -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace court-auction-notice-search --dry-run && npm pack --workspace donation-place-search --dry-run && npm pack --workspace gongsijiga-search --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run && npm pack --workspace k-skill-rhwp --dry-run",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace court-auction-notice-search --dry-run && npm pack --workspace donation-place-search --dry-run && npm pack --workspace gongsijiga-search --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run && npm pack --workspace k-skill-rhwp --dry-run && npm pack --workspace korean-marathon-schedule --dry-run",
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
"version-packages": "changeset version",
"release:npm": "changeset publish"

View file

@ -0,0 +1,34 @@
# 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 public race detail pages.
- Triathlon: `https://triathlon.or.kr/events/tour/?sYear=<year>&vType=list` and public federation detail pages.
Both sources are unauthenticated public web surfaces. No proxy or API key is required.
## 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
})
console.log(result.items)
```
CLI:
```bash
npx korean-marathon-schedule 서울 --from 2026-05-01 --to 2026-12-31 --include-triathlon --limit 5
```
Returned event fields include `title`, `eventDate`, `region`, `venue`, `registrationDeadline`, `registrationPeriod`, `categories`, `organizer`, `officialUrl`, and source `url`.

View file

@ -0,0 +1,35 @@
{
"name": "korean-marathon-schedule",
"version": "0.1.0",
"description": "Public Korean marathon and triathlon schedule lookup client",
"license": "MIT",
"main": "src/index.js",
"bin": {
"korean-marathon-schedule": "src/cli.js"
},
"files": [
"src",
"README.md"
],
"engines": {
"node": ">=18"
},
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/NomaDamas/k-skill.git"
},
"keywords": [
"k-skill",
"marathon",
"running",
"triathlon",
"korea"
],
"scripts": {
"lint": "node --check src/index.js && node --check src/cli.js && node --check test/index.test.js",
"test": "node --test"
}
}

View file

@ -0,0 +1,36 @@
#!/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 === "--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 --include-triathlon Include 대한철인3종협회 triathlon events when possible\n`)
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : String(error))
process.exitCode = 1
})

View file

@ -0,0 +1,408 @@
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,
fetcher = global.fetch
} = options
if (!fetcher) throw new Error("fetch is required.")
const normalizedLimit = Math.max(1, Number(limit) || 10)
const years = collectYears(from, to)
const items = []
const warnings = []
try {
const marathonListHtml = await fetchText(fetcher, GORUNNING_RACES_URL)
const marathonUrls = parseGorunningList(marathonListHtml)
for (const url of marathonUrls.slice(0, Math.max(normalizedLimit * 3, normalizedLimit))) {
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 && !includeTriathlon) break
}
} catch (error) {
warnings.push(`gorunning source failed: ${error.message}`)
}
if (includeTriathlon) {
for (const year of years) {
const listUrl = `${TRIATHLON_TOUR_URL}?sYear=${encodeURIComponent(year)}&vType=list`
try {
const triListHtml = await fetchText(fetcher, listUrl)
for (const listItem of parseTriathlonList(triListHtml).slice(0, Math.max(normalizedLimit * 2, normalizedLimit))) {
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
}
}
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 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))) {
urls.add(new URL(decodeHtml(match[1]), GORUNNING_RACES_URL).toString())
}
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(plain, address || locationLine)
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 = new URL(decodeHtml(match[1]), "https://triathlon.or.kr").toString()
const context = source.slice(Math.max(0, match.index - 300), Math.min(source.length, match.index + 700))
const categories = splitCategories(textAfterInlineLabel(htmlToText(context), "코스"))
items.set(url, { url, categories })
}
return [...items.values()]
}
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(text, location) {
const candidates = ["서울", "부산", "대구", "인천", "광주", "대전", "울산", "세종", "경기", "강원", "충북", "충남", "전북", "전남", "경북", "경남", "제주"]
const haystack = cleanText(`${text || ""} ${location || ""}`)
return candidates.find((candidate) => haystack.includes(candidate)) || normalizeRegion(String(location || "").split(/\s+/)[0])
}
function normalizeRegion(region) {
const value = cleanText(region || "")
const map = {
서울특별시: "서울",
부산광역시: "부산",
대구광역시: "대구",
인천광역시: "인천",
광주광역시: "광주",
대전광역시: "대전",
울산광역시: "울산",
세종특별자치시: "세종",
경기도: "경기",
강원도: "강원",
충청북도: "충북",
충청남도: "충남",
전라북도: "전북",
전라남도: "전남",
경상북도: "경북",
경상남도: "경남",
제주특별자치도: "제주"
}
return map[value] || value || null
}
function detectStatus(text) {
const plain = cleanText(text)
if (/등록중|접수중|참가 신청 가능/.test(plain)) return plain.includes("접수중") ? "접수중" : "등록중"
if (/마감|등록마감|접수마감/.test(plain)) return "마감"
return null
}
function extractGorunningCategories(text, title) {
const plain = cleanText(text)
const cleanTitle = cleanText(title || "")
if (cleanTitle) {
const escaped = escapeRegExp(cleanTitle)
const pipeMatch = plain.match(new RegExp(`${escaped}\\s*\\|\\s*([^|]{1,120}?)\\s*\\|\\s*\\d{4}[./-]\\d{1,2}[./-]\\d{1,2}`, "i"))
if (pipeMatch) return extractRaceCategories(pipeMatch[1])
const idx = plain.indexOf(cleanTitle)
if (idx >= 0) {
const tail = plain.slice(idx + cleanTitle.length, idx + cleanTitle.length + 300)
const dateIdx = tail.search(/\d{4}[./-]\d{1,2}[./-]\d{1,2}/)
return extractRaceCategories(dateIdx >= 0 ? tail.slice(0, dateIdx) : tail)
}
}
return extractRaceCategories(plain.slice(0, 500))
}
function extractRaceCategories(text) {
const plain = cleanText(text)
const categories = []
const patterns = [
[/풀(?:코스)?|Full/gi, "Full"],
[/하프|Half/gi, "Half"],
[/\b10\s?km\b/gi, "10km"],
[/\b5\s?km\b/gi, "5km"],
[/\b3\s?km\s*걷기/gi, "3km 걷기"],
[/\b3\s?km\s*걷기\(어린이\)/gi, "3km 걷기(어린이)"]
]
for (const [pattern, label] of patterns) {
if (pattern.test(plain) && !categories.includes(label)) categories.push(label)
}
return categories
}
function splitCategories(text) {
return cleanText(text || "")
.split(/[,/·|]/)
.map((item) => cleanText(item))
.filter(Boolean)
}
function textAfterInlineLabel(text, label) {
const plain = cleanText(text)
const match = plain.match(new RegExp(`${escapeRegExp(label)}\\s*[:]\\s*([^\\n]+?)(?:\\s{2,}|$)`))
return match ? cleanText(match[1]) : null
}
function findOfficialUrl(html, fallbackUrl) {
const source = String(html || "")
const websiteBlock = source.match(/웹사이트[\s\S]{0,500}?<a\b[^>]*href=["'](https?:\/\/[^"']+)["'][^>]*>/i)
if (websiteBlock) return decodeHtml(websiteBlock[1])
const links = [...source.matchAll(/<a\b[^>]*href=["'](https?:\/\/[^"']+)["'][^>]*>/gi)].map((m) => decodeHtml(m[1]))
return links.find((link) => !link.includes("gorunning.kr") && !link.includes("map.naver.com")) || links.find((link) => !link.includes("gorunning.kr")) || fallbackUrl
}
function compactEvent(event) {
return {
source: event.source,
type: event.type,
title: event.title || null,
eventDate: event.eventDate || null,
region: event.region || null,
venue: event.venue || null,
registrationDeadline: event.registrationDeadline || null,
registrationPeriod: event.registrationPeriod || { start: null, end: null },
status: event.status || null,
categories: event.categories || [],
organizer: event.organizer || null,
officialUrl: event.officialUrl || null,
url: event.url || null
}
}
function htmlToText(html) {
return decodeHtml(String(html || "")
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>|<\/div>|<\/tr>|<\/h[1-6]>/gi, "\n")
.replace(/<[^>]+>/g, " "))
}
function cleanText(value) {
return decodeHtml(String(value || ""))
.replace(/\u00a0/g, " ")
.replace(/[ \t\r\f\v]+/g, " ")
.replace(/\n\s+/g, "\n")
.trim()
}
function decodeHtml(value) {
return String(value || "")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
module.exports = {
searchEvents,
parseGorunningList,
parseGorunningDetail,
parseTriathlonList,
parseTriathlonDetail,
GORUNNING_RACES_URL,
TRIATHLON_TOUR_URL
}

View file

@ -0,0 +1,194 @@
const test = require("node:test")
const assert = require("node:assert/strict")
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("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("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"
}
})
}