mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
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:
parent
91eeaf607a
commit
341a2b00d3
11 changed files with 915 additions and 1 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.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
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종협회 상세 링크를 대신 제공합니다.
|
||||
120
korean-marathon-schedule/SKILL.md
Normal file
120
korean-marathon-schedule/SKILL.md
Normal 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
14
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
|
||||
|
|
@ -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 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"
|
||||
|
|
|
|||
34
packages/korean-marathon-schedule/README.md
Normal file
34
packages/korean-marathon-schedule/README.md
Normal 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`.
|
||||
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"
|
||||
}
|
||||
}
|
||||
36
packages/korean-marathon-schedule/src/cli.js
Executable file
36
packages/korean-marathon-schedule/src/cli.js
Executable 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
|
||||
})
|
||||
408
packages/korean-marathon-schedule/src/index.js
Normal file
408
packages/korean-marathon-schedule/src/index.js
Normal 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(/&/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
|
||||
}
|
||||
194
packages/korean-marathon-schedule/test/index.test.js
Normal file
194
packages/korean-marathon-schedule/test/index.test.js
Normal 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"
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue