mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Feature/#129 (#131)
* Add official KBL results support so basketball queries use live league data Issue #129 needs a read-only skill and reusable package for KBL schedules, results, and standings. The implementation follows the existing sports package pattern and uses the league's live JSON APIs after verifying they respond successfully in real requests. Constraint: Must use official KBL JSON surfaces before considering scraping Constraint: Packaging changes must pass npm run ci and include docs plus Changesets updates Rejected: Browser scraping first | official api.kbl.or.kr endpoints are live and simpler to maintain Rejected: Reuse KBO/K League package shapes verbatim | KBL payload and team/status fields differ materially Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep seasonGrade=1 as the default KBL path unless future docs/tests explicitly widen to D-League flows Tested: npm run ci; npm run lint --workspace kbl-results; npm test --workspace kbl-results; live getKBLSummary("2026-04-01", { team: "KCC", includeStandings: true }) Not-tested: Historical standings snapshots for past seasons via alternative KBL endpoints * Prevent optional standings lookups from over-fetching the KBL API The new kbl-results summary helper exposes includeStandings=false, so the regression suite now proves that path stays schedule-only and never calls the standings endpoint when the caller opts out. Constraint: The KBL package should preserve the caller's no-standings contract Rejected: Rely on manual inspection of the helper options | a targeted test is cheaper and safer Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep includeStandings=false side-effect free unless the public API contract changes explicitly Tested: npm test --workspace kbl-results; npm run lint --workspace kbl-results Not-tested: Full-repo CI before stacking this commit onto the rebased branch
This commit is contained in:
parent
c9116b555f
commit
7c0bfa4c93
18 changed files with 2595 additions and 3 deletions
5
.changeset/issue-129-kbl-results.md
Normal file
5
.changeset/issue-129-kbl-results.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"kbl-results": minor
|
||||
---
|
||||
|
||||
Add a reusable KBL results and standings package backed by the official JSON APIs.
|
||||
|
|
@ -42,6 +42,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
| 근처 가장 싼 주유소 찾기 | 현재 위치 기준 근처 최저가 주유소 조회 | 불필요 | [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md) |
|
||||
| 근처 공중화장실 찾기 | 현재 위치 기준 근처 공중화장실/개방화장실 조회 | 불필요 | [근처 공중화장실 찾기 가이드](docs/features/public-restroom-nearby.md) |
|
||||
| KBO 경기 결과 조회 | 날짜별 KBO 경기 일정, 결과, 팀별 필터링 | 불필요 | [KBO 결과 가이드](docs/features/kbo-results.md) |
|
||||
| KBL 경기 결과 조회 | 날짜별 KBL 경기 일정, 결과, 팀별 필터링, 현재 순위 확인 | 불필요 | [KBL 경기 결과 가이드](docs/features/kbl-results.md) |
|
||||
| K리그 경기 결과 조회 | 날짜별 K리그1/K리그2 경기 결과, 팀별 필터링, 현재 순위 확인 | 불필요 | [K리그 결과 가이드](docs/features/kleague-results.md) |
|
||||
| LCK 경기 분석 | LCK 경기 결과, 현재 순위, live turning point, 밴픽, 패치 메타, 팀 파워 레이팅 | 불필요 | [LCK 경기 분석 가이드](docs/features/lck-analytics.md) |
|
||||
| 토스증권 조회 | 토스증권 계좌 요약, 포트폴리오, 시세, 주문내역, 관심종목 조회 | 필요 | [토스증권 조회 가이드](docs/features/toss-securities.md) |
|
||||
|
|
@ -115,6 +116,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
- [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md)
|
||||
- [근처 공중화장실 찾기 가이드](docs/features/public-restroom-nearby.md)
|
||||
- [KBO 경기 결과 조회](docs/features/kbo-results.md)
|
||||
- [KBL 경기 결과 가이드](docs/features/kbl-results.md)
|
||||
- [K리그 경기 결과 조회](docs/features/kleague-results.md)
|
||||
- [LCK 경기 분석 가이드](docs/features/lck-analytics.md)
|
||||
- [토스증권 조회 가이드](docs/features/toss-securities.md)
|
||||
|
|
|
|||
59
docs/features/kbl-results.md
Normal file
59
docs/features/kbl-results.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# KBL 경기 결과 가이드
|
||||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
- 날짜별 KBL 경기 일정 및 결과 조회
|
||||
- 특정 팀(`서울 SK`, `부산 KCC`, 팀 코드 등) 경기만 필터링
|
||||
- 현재 순위 확인
|
||||
|
||||
## 먼저 필요한 것
|
||||
|
||||
- Node.js 18+
|
||||
- `npm install -g kbl-results`
|
||||
|
||||
## 입력값
|
||||
|
||||
- 날짜: `YYYY-MM-DD`
|
||||
- 선택 사항: 팀명, 풀네임, 팀 코드
|
||||
|
||||
## 공식 표면
|
||||
|
||||
이 기능은 브라우저 크롤링 전에 공식 JSON 표면을 직접 사용한다.
|
||||
|
||||
- KBL 일정/결과 API: `https://api.kbl.or.kr/match/list`
|
||||
- KBL 팀 순위 API: `https://api.kbl.or.kr/league/rank/team`
|
||||
|
||||
## 기본 흐름
|
||||
|
||||
1. 패키지가 없으면 다른 방법으로 우회하지 말고 먼저 `kbl-results` 를 전역 설치한다.
|
||||
2. `match/list` 에 `fromDate` / `toDate` / `tcodeList` / `seasonGrade=1` 을 넣어 날짜별 경기 데이터를 가져온다.
|
||||
3. 요청 팀이 있으면 `서울 SK`, `SK`, `55`, `부산 KCC`, `KCC` 같은 alias 를 같은 팀으로 인식해 걸러낸다.
|
||||
4. `league/rank/team` 으로 현재 순위를 가져와 경기 결과와 함께 보여준다.
|
||||
|
||||
## 예시
|
||||
|
||||
```bash
|
||||
GLOBAL_NPM_ROOT="$(npm root -g)" node --input-type=module - <<'JS'
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const entry = pathToFileURL(
|
||||
path.join(process.env.GLOBAL_NPM_ROOT, "kbl-results", "src", "index.js"),
|
||||
).href;
|
||||
const { getKBLSummary } = await import(entry);
|
||||
|
||||
const summary = await getKBLSummary("2026-04-01", {
|
||||
team: "서울 SK",
|
||||
includeStandings: true,
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
JS
|
||||
```
|
||||
|
||||
## 주의할 점
|
||||
|
||||
- `match/list` 는 `YYYYMMDD` 파라미터를 받는다. 라이브러리가 `YYYY-MM-DD` 입력을 공식 포맷으로 바꾼다.
|
||||
- 기본 조회는 KBL 1군 기준이라 `seasonGrade=1` 을 사용한다.
|
||||
- 현재 순위는 `league/rank/team` 기준 현재 표를 사용한다.
|
||||
- 공식 JSON이 살아 있으므로 브라우저 scraping 은 기본 경로가 아니다.
|
||||
|
|
@ -46,6 +46,7 @@ k-skill-setup 스킬을 사용해서 공통 설정을 진행해줘.
|
|||
npx --yes skills add <owner/repo> \
|
||||
--skill hwp \
|
||||
--skill kbo-results \
|
||||
--skill kbl-results \
|
||||
--skill kleague-results \
|
||||
--skill lck-analytics \
|
||||
--skill toss-securities \
|
||||
|
|
@ -260,7 +261,7 @@ npm run ci
|
|||
### Node 패키지
|
||||
|
||||
```bash
|
||||
npm install -g kordoc pdfjs-dist kbo-game kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby public-restroom-nearby korean-law-mcp market-kurly-search daiso bunjang-cli
|
||||
npm install -g kordoc pdfjs-dist kbo-game kbl-results kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby public-restroom-nearby korean-law-mcp market-kurly-search daiso bunjang-cli
|
||||
export NODE_PATH="$(npm root -g)"
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
- SRT
|
||||
- KTX
|
||||
- KBO 경기 결과
|
||||
- KBL 경기 결과 조회 스킬 출시
|
||||
- K리그 경기 결과 조회 스킬 출시
|
||||
- LCK 경기 분석 스킬 출시
|
||||
- 토스증권 조회 스킬 출시
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
- `korail2` / `carpedm20/korail2`: https://github.com/carpedm20/korail2
|
||||
- `korail2` anti-bot bypass PR #54: https://github.com/carpedm20/korail2/pull/54
|
||||
- `kbo-game`: https://github.com/vkehfdl1/kbo-game
|
||||
- KBL 일정/결과 API: https://api.kbl.or.kr/match/list
|
||||
- KBL 팀 순위 API: https://api.kbl.or.kr/league/rank/team
|
||||
- tossinvest-cli: https://github.com/JungHoonGhae/tossinvest-cli
|
||||
- 하이패스 메인: https://www.hipass.co.kr/main.do
|
||||
- 하이패스 로그인: https://www.hipass.co.kr/comm/lginpg.do
|
||||
|
|
|
|||
99
kbl-results/SKILL.md
Normal file
99
kbl-results/SKILL.md
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
---
|
||||
name: kbl-results
|
||||
description: KBL 한국프로농구 경기 결과와 현재 팀 순위를 날짜/팀 기준으로 조회한다. 공식 JSON 엔드포인트와 kbl-results npm 패키지를 사용한다.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: sports
|
||||
locale: ko-KR
|
||||
phase: v1
|
||||
---
|
||||
|
||||
# KBL Results
|
||||
|
||||
## What this skill does
|
||||
|
||||
공식 KBL JSON 표면으로 특정 날짜의 한국프로농구 경기 일정/결과를 조회하고, 필요하면 특정 팀(예: `서울 SK`, `부산 KCC`, 팀 코드 `55`)만 필터링한 뒤 현재 팀 순위까지 함께 정리한다.
|
||||
|
||||
## When to use
|
||||
|
||||
- "오늘 KBL 경기 결과 알려줘"
|
||||
- "2026-04-01 서울 SK 경기 결과 보여줘"
|
||||
- "KBL 현재 팀 순위 알려줘"
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- `npm install -g kbl-results`
|
||||
|
||||
## Inputs
|
||||
|
||||
- 날짜: `YYYY-MM-DD`
|
||||
- 선택 사항: 팀명, 풀네임, 팀 코드
|
||||
|
||||
## Workflow
|
||||
|
||||
### 0. Install the package globally when missing
|
||||
|
||||
`npm root -g` 아래에 `kbl-results` 가 없으면 HTML scraping 으로 우회하지 말고 먼저 전역 Node 패키지 설치를 시도한다.
|
||||
|
||||
```bash
|
||||
npm install -g kbl-results
|
||||
```
|
||||
|
||||
### 1. Fetch the official KBL JSON
|
||||
|
||||
공식 KBL 웹앱은 `https://api.kbl.or.kr` JSON API를 사용한다. 따라서 브라우저 크롤링 전에 아래 표면을 우선 사용한다.
|
||||
|
||||
- 일정/결과: `https://api.kbl.or.kr/match/list`
|
||||
- 팀 순위: `https://api.kbl.or.kr/league/rank/team`
|
||||
|
||||
```bash
|
||||
GLOBAL_NPM_ROOT="$(npm root -g)" node --input-type=module - <<'JS'
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const entry = pathToFileURL(
|
||||
path.join(process.env.GLOBAL_NPM_ROOT, "kbl-results", "src", "index.js"),
|
||||
).href;
|
||||
const { getKBLSummary } = await import(entry);
|
||||
|
||||
const summary = await getKBLSummary("2026-04-01", {
|
||||
team: "부산 KCC",
|
||||
includeStandings: true,
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
JS
|
||||
```
|
||||
|
||||
### 2. Normalize for humans
|
||||
|
||||
원본 JSON을 그대로 던지지 말고 아래 기준으로 정리한다.
|
||||
|
||||
- 홈팀 vs 원정팀
|
||||
- 경기 시간 / 종료 여부 / LIVE 여부
|
||||
- 스코어
|
||||
- 현재 순위
|
||||
- 요청 팀이 있으면 해당 팀 경기만 필터링
|
||||
|
||||
### 3. Keep the answer compact
|
||||
|
||||
요청이 scoreboard 면 경기별 한 줄 요약부터 준다. 특정 팀 요청이면 그 팀 경기와 현재 순위만 먼저 보여준다.
|
||||
|
||||
## Done when
|
||||
|
||||
- 날짜 기준 경기 요약이 있다
|
||||
- 팀 요청이면 해당 팀 경기만 남아 있다
|
||||
- 현재 순위가 같이 정리되어 있다
|
||||
|
||||
## Failure modes
|
||||
|
||||
- KBL가 `api.kbl.or.kr` 응답 구조를 바꾸면 패키지 수정이 필요하다
|
||||
- 경기 전 날짜면 결과 대신 예정 상태가 반환될 수 있다
|
||||
- 크롤링 fallback은 공식 JSON이 막혔을 때만 검토한다
|
||||
|
||||
## Notes
|
||||
|
||||
- 이 스킬은 조회 전용이다
|
||||
- 사용자의 "오늘/어제" 요청은 항상 절대 날짜(`YYYY-MM-DD`)로 변환해서 실행한다
|
||||
- 자세한 사용 예시는 `docs/features/kbl-results.md` 와 `packages/kbl-results/README.md` 를 따른다
|
||||
11
package-lock.json
generated
11
package-lock.json
generated
|
|
@ -1007,6 +1007,10 @@
|
|||
"resolved": "packages/kakao-bar-nearby",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/kbl-results": {
|
||||
"resolved": "packages/kbl-results",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/kleague-results": {
|
||||
"resolved": "packages/kleague-results",
|
||||
"link": true
|
||||
|
|
@ -1751,6 +1755,13 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/kbl-results": {
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/kleague-results": {
|
||||
"version": "0.2.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/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/kakaotalk_mac.py scripts/test_kakaotalk_mac.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-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.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_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_kakaotalk_mac && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.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 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",
|
||||
"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 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",
|
||||
|
||||
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
|
||||
"version-packages": "changeset version",
|
||||
|
|
|
|||
5
packages/kbl-results/CHANGELOG.md
Normal file
5
packages/kbl-results/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# kbl-results
|
||||
|
||||
## 0.1.0
|
||||
|
||||
- Initial release.
|
||||
59
packages/kbl-results/README.md
Normal file
59
packages/kbl-results/README.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# kbl-results
|
||||
|
||||
공식 KBL JSON 엔드포인트를 감싼 재사용 가능한 Node.js 클라이언트입니다. 날짜별 경기 결과와 현재 순위를 함께 조회할 수 있습니다.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install kbl-results
|
||||
```
|
||||
|
||||
## Official surfaces
|
||||
|
||||
- 일정/결과: `https://api.kbl.or.kr/match/list`
|
||||
- 팀 순위: `https://api.kbl.or.kr/league/rank/team`
|
||||
|
||||
## Usage
|
||||
|
||||
```js
|
||||
const { getKBLSummary, getMatchResults, getStandings } = require("kbl-results");
|
||||
|
||||
(async () => {
|
||||
const results = await getMatchResults("2026-04-01", {
|
||||
team: "서울 SK",
|
||||
});
|
||||
|
||||
const standings = await getStandings();
|
||||
|
||||
const summary = await getKBLSummary("2026-04-01", {
|
||||
team: "부산 KCC",
|
||||
includeStandings: true,
|
||||
});
|
||||
|
||||
console.log(results.matches[0]);
|
||||
console.log(standings.rows[0]);
|
||||
console.log(summary);
|
||||
})();
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `getMatchResults(date, options)`
|
||||
|
||||
- `date`: `YYYY-MM-DD` 또는 `Date`
|
||||
- `options.team`: short name / full name / team code alias
|
||||
- `options.seasonGrade`: 기본값은 `1` (KBL 1군)
|
||||
|
||||
### `getStandings()`
|
||||
|
||||
- 현재 KBL 팀 순위를 반환합니다.
|
||||
|
||||
### `getKBLSummary(date, options)`
|
||||
|
||||
- 날짜 결과와 현재 순위를 한 번에 반환합니다.
|
||||
|
||||
## Notes
|
||||
|
||||
- 공식 KBL JSON 엔드포인트 기준이라 HTML 크롤링보다 유지보수가 단순합니다.
|
||||
- `match/list` 는 `fromDate` / `toDate` 를 `YYYYMMDD` 형식으로 받습니다.
|
||||
- 1군 KBL 조회 기본값은 `seasonGrade=1` 입니다.
|
||||
31
packages/kbl-results/package.json
Normal file
31
packages/kbl-results/package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "kbl-results",
|
||||
"version": "0.1.0",
|
||||
"description": "Official KBL match results and standings client",
|
||||
"license": "MIT",
|
||||
"main": "src/index.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",
|
||||
"kbl",
|
||||
"basketball",
|
||||
"korea"
|
||||
],
|
||||
"scripts": {
|
||||
"lint": "node --check src/index.js && node --check src/parse.js && node --check test/index.test.js",
|
||||
"test": "node --test"
|
||||
}
|
||||
}
|
||||
123
packages/kbl-results/src/index.js
Normal file
123
packages/kbl-results/src/index.js
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
const {
|
||||
normalizeDateInput,
|
||||
normalizeScheduleResponse,
|
||||
normalizeStandingsResponse,
|
||||
} = require("./parse");
|
||||
|
||||
const MATCH_LIST_URL = "https://api.kbl.or.kr/match/list";
|
||||
const TEAM_RANK_URL = "https://api.kbl.or.kr/league/rank/team";
|
||||
const DEFAULT_HEADERS = {
|
||||
accept: "application/json, text/plain, */*",
|
||||
"accept-language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
"user-agent": "k-skill/kbl-results",
|
||||
"x-requested-with": "XMLHttpRequest",
|
||||
channel: "WEB",
|
||||
teamcode: "XX",
|
||||
lang: "ko",
|
||||
};
|
||||
|
||||
async function requestJson(url, options = {}) {
|
||||
const fetchImpl = options.fetchImpl || global.fetch;
|
||||
|
||||
if (typeof fetchImpl !== "function") {
|
||||
throw new Error("A fetch implementation is required.");
|
||||
}
|
||||
|
||||
const response = await fetchImpl(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
...DEFAULT_HEADERS,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`KBL request failed with ${response.status} for ${url}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function fetchMatchList({ date, seasonGrade = 1, tcodeList = "all", fetchImpl, signal }) {
|
||||
const queryDate = normalizeDateInput(date);
|
||||
const url = new URL(MATCH_LIST_URL);
|
||||
url.searchParams.set("fromDate", queryDate.compactDate);
|
||||
url.searchParams.set("toDate", queryDate.compactDate);
|
||||
url.searchParams.set("tcodeList", tcodeList);
|
||||
if (seasonGrade != null) {
|
||||
url.searchParams.set("seasonGrade", String(seasonGrade));
|
||||
}
|
||||
|
||||
return requestJson(url.toString(), {
|
||||
fetchImpl,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchStandings({ fetchImpl, signal }) {
|
||||
return requestJson(TEAM_RANK_URL, {
|
||||
fetchImpl,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
async function getMatchResults(date, options = {}) {
|
||||
const payload = options.schedulePayload || await fetchMatchList({
|
||||
date,
|
||||
seasonGrade: options.seasonGrade == null ? 1 : options.seasonGrade,
|
||||
tcodeList: options.tcodeList || "all",
|
||||
fetchImpl: options.fetchImpl,
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
return normalizeScheduleResponse(payload, {
|
||||
date,
|
||||
team: options.team,
|
||||
seasonGrade: options.seasonGrade == null ? 1 : options.seasonGrade,
|
||||
standingsRows: options.standingsRows,
|
||||
});
|
||||
}
|
||||
|
||||
async function getStandings(options = {}) {
|
||||
const payload = options.standingsPayload || await fetchStandings({
|
||||
fetchImpl: options.fetchImpl,
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
return normalizeStandingsResponse(payload);
|
||||
}
|
||||
|
||||
async function getKBLSummary(date, options = {}) {
|
||||
const standingsPayload = options.includeStandings === false
|
||||
? null
|
||||
: (options.standingsPayload || await fetchStandings({
|
||||
fetchImpl: options.fetchImpl,
|
||||
signal: options.signal,
|
||||
}));
|
||||
|
||||
const matches = options.matchesResponse || await getMatchResults(date, {
|
||||
...options,
|
||||
standingsRows: standingsPayload || undefined,
|
||||
});
|
||||
|
||||
const summary = {
|
||||
queryDate: matches.queryDate,
|
||||
filteredTeam: matches.filteredTeam,
|
||||
matches: matches.matches,
|
||||
};
|
||||
|
||||
if (options.includeStandings !== false) {
|
||||
summary.standings = normalizeStandingsResponse(standingsPayload);
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchMatchList,
|
||||
fetchStandings,
|
||||
getKBLSummary,
|
||||
getMatchResults,
|
||||
getStandings,
|
||||
};
|
||||
436
packages/kbl-results/src/parse.js
Normal file
436
packages/kbl-results/src/parse.js
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
const KNOWN_TEAMS = [
|
||||
{ code: "50", name: "창원 LG", fullName: "창원 LG 세이커스", logoClass: "lg", aliases: ["LG", "세이커스"] },
|
||||
{ code: "70", name: "안양 정관장", fullName: "안양 정관장 레드부스터스", logoClass: "kgc", aliases: ["정관장", "KGC", "레드부스터스"] },
|
||||
{ code: "16", name: "원주 DB", fullName: "원주 DB 프로미", logoClass: "db", aliases: ["DB", "프로미"] },
|
||||
{ code: "55", name: "서울 SK", fullName: "서울 SK 나이츠", logoClass: "sk", aliases: ["SK", "나이츠"] },
|
||||
{ code: "66", name: "고양 소노", fullName: "고양 소노 스카이거너스", logoClass: "sono", aliases: ["소노", "스카이거너스", "SONO"] },
|
||||
{ code: "60", name: "부산 KCC", fullName: "부산 KCC 이지스", logoClass: "kcc", aliases: ["KCC", "이지스"] },
|
||||
{ code: "06", name: "수원 KT", fullName: "수원 KT 소닉붐", logoClass: "kt", aliases: ["KT", "소닉붐"] },
|
||||
{ code: "10", name: "울산 현대모비스", fullName: "울산 현대모비스 피버스", logoClass: "hd", aliases: ["현대모비스", "모비스", "피버스"] },
|
||||
{ code: "64", name: "대구 한국가스공사", fullName: "대구 한국가스공사 페가수스", logoClass: "pega", aliases: ["한국가스공사", "가스공사", "페가수스"] },
|
||||
{ code: "35", name: "서울 삼성", fullName: "서울 삼성 썬더스", logoClass: "ss", aliases: ["삼성", "썬더스"] },
|
||||
];
|
||||
|
||||
const STATUS_MAP = {
|
||||
live: { code: "LIVE", state: "live", label: "진행 중" },
|
||||
finished: { code: "ENDED", state: "finished", label: "종료" },
|
||||
scheduled: { code: "SCHEDULED", state: "scheduled", label: "예정" },
|
||||
};
|
||||
|
||||
function normalizeDateInput(value) {
|
||||
if (value instanceof Date) {
|
||||
if (Number.isNaN(value.getTime())) {
|
||||
throw new Error("date must be a valid Date or YYYY-MM-DD string.");
|
||||
}
|
||||
|
||||
const formatter = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Asia/Seoul",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
const parts = formatter.formatToParts(value).reduce((acc, part) => {
|
||||
if (part.type !== "literal") {
|
||||
acc[part.type] = part.value;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return buildDateParts(parts.year, parts.month, parts.day);
|
||||
}
|
||||
|
||||
const match = String(value || "").trim().match(/^(\d{4})[-.](\d{2})[-.](\d{2})$/);
|
||||
if (!match || !isValidCalendarDate(match[1], match[2], match[3])) {
|
||||
throw new Error("date must be a valid Date or YYYY-MM-DD string.");
|
||||
}
|
||||
|
||||
return buildDateParts(match[1], match[2], match[3]);
|
||||
}
|
||||
|
||||
function normalizeScheduleResponse(payload, options = {}) {
|
||||
const queryDate = options.date ? normalizeDateInput(options.date) : null;
|
||||
const seasonGrade = options.seasonGrade == null ? 1 : Number(options.seasonGrade);
|
||||
const teamDirectory = buildTeamDirectory({
|
||||
scheduleRows: payload,
|
||||
standingsRows: options.standingsRows,
|
||||
});
|
||||
const requestedTeam = options.team ? resolveTeamQuery(options.team, teamDirectory) : null;
|
||||
const rows = Array.isArray(payload) ? payload : [];
|
||||
|
||||
const matches = rows
|
||||
.filter((item) => Number(item.seasonGrade || 1) === seasonGrade)
|
||||
.filter((item) => !queryDate || item.gameDate === queryDate.compactDate)
|
||||
.filter((item) => !requestedTeam || itemMatchesRequestedTeam(item, requestedTeam))
|
||||
.map((item) => normalizeScheduleItem(item, teamDirectory))
|
||||
.sort(compareMatches);
|
||||
|
||||
return {
|
||||
queryDate: queryDate?.isoDate ?? null,
|
||||
seasonGrade,
|
||||
filteredTeam: requestedTeam
|
||||
? {
|
||||
input: requestedTeam.input,
|
||||
normalized: requestedTeam.fullName,
|
||||
code: requestedTeam.code,
|
||||
}
|
||||
: null,
|
||||
matches,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStandingsResponse(payload) {
|
||||
const teamDirectory = buildTeamDirectory({ standingsRows: payload });
|
||||
const rows = (Array.isArray(payload) ? payload : [])
|
||||
.map((item) => {
|
||||
const team = stripAliasTokens(getTeam(item.tcode, item.tname, item.tnameF, item.teamLogoClass, teamDirectory));
|
||||
const win = normalizeNumber(item.win) ?? 0;
|
||||
const loss = normalizeNumber(item.loss) ?? 0;
|
||||
const draw = normalizeNumber(item.draw) ?? 0;
|
||||
|
||||
return {
|
||||
rank: normalizeNumber(item.rank),
|
||||
team,
|
||||
win,
|
||||
loss,
|
||||
draw,
|
||||
gamesBehind: normalizeNumber(item.winDiff) ?? 0,
|
||||
winningPercentage: calculateWinningPercentage(win, loss, draw),
|
||||
home: {
|
||||
win: normalizeNumber(item.hwin) ?? 0,
|
||||
loss: normalizeNumber(item.hloss) ?? 0,
|
||||
},
|
||||
away: {
|
||||
win: normalizeNumber(item.awin) ?? 0,
|
||||
loss: normalizeNumber(item.aloss) ?? 0,
|
||||
},
|
||||
streak: {
|
||||
win: normalizeNumber(item.contiWin) ?? 0,
|
||||
loss: normalizeNumber(item.contiLoss) ?? 0,
|
||||
},
|
||||
maxStreak: {
|
||||
win: normalizeNumber(item.maxWin) ?? 0,
|
||||
loss: normalizeNumber(item.maxLoss) ?? 0,
|
||||
},
|
||||
lastFive: normalizeLastRecord(item.lastRecord),
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.rank - right.rank);
|
||||
|
||||
return {
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeScheduleItem(item, teamDirectory) {
|
||||
const homeTeam = stripAliasTokens(getTeam(item.tcodeH, item.tnameH, item.tnameFH, item.logoH, teamDirectory));
|
||||
const awayTeam = stripAliasTokens(getTeam(item.tcodeA, item.tnameA, item.tnameFA, item.logoA, teamDirectory));
|
||||
const status = normalizeMatchStatus(item);
|
||||
const score = {
|
||||
home: normalizeNumber(item.scoreH),
|
||||
away: normalizeNumber(item.scoreA),
|
||||
};
|
||||
|
||||
return {
|
||||
gameKey: item.gmkey || null,
|
||||
gameNumber: normalizeNumber(item.gameNo),
|
||||
gameCode: item.gameCode || null,
|
||||
seasonCode: normalizeNumber(item.seasonCode),
|
||||
seasonGrade: normalizeNumber(item.seasonGrade),
|
||||
competitionName: item.seasonName1 || null,
|
||||
seasonCategory: {
|
||||
code: item.seasonCategory || null,
|
||||
label: item.seasonCategoryName || null,
|
||||
},
|
||||
date: compactDateToIso(item.gameDate),
|
||||
dateLabel: item.gameDate || null,
|
||||
weekDay: item.weekDay || null,
|
||||
startTime: compactTimeToClock(item.gameStart),
|
||||
endTime: compactTimeToClock(item.gameEnd),
|
||||
status,
|
||||
homeTeam,
|
||||
awayTeam,
|
||||
score,
|
||||
winner: determineWinner(score, status, homeTeam, awayTeam),
|
||||
venue: {
|
||||
shortName: item.stadiumname || null,
|
||||
name: item.stadiumnameF || item.stadiumname || null,
|
||||
},
|
||||
broadcastChannels: splitBroadcastChannels(item.tv),
|
||||
};
|
||||
}
|
||||
|
||||
function buildTeamDirectory({ scheduleRows = [], standingsRows = [] } = {}) {
|
||||
const directory = new Map();
|
||||
|
||||
for (const team of KNOWN_TEAMS) {
|
||||
upsertTeam(directory, team.code, team.name, team.fullName, team.logoClass, team.aliases);
|
||||
}
|
||||
|
||||
for (const item of scheduleRows || []) {
|
||||
upsertTeam(directory, item.tcodeH, item.tnameH, item.tnameFH, item.logoH);
|
||||
upsertTeam(directory, item.tcodeA, item.tnameA, item.tnameFA, item.logoA);
|
||||
}
|
||||
|
||||
for (const item of standingsRows || []) {
|
||||
upsertTeam(directory, item.tcode, item.tname, item.tnameF, item.teamLogoClass);
|
||||
}
|
||||
|
||||
return directory;
|
||||
}
|
||||
|
||||
function upsertTeam(directory, code, name, fullName, logoClass, aliases = []) {
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = directory.get(code) || {
|
||||
code,
|
||||
name: name || code,
|
||||
fullName: fullName || name || code,
|
||||
logoClass: logoClass || null,
|
||||
aliasTokens: new Set(),
|
||||
};
|
||||
|
||||
if (name) {
|
||||
existing.name = name;
|
||||
}
|
||||
if (fullName) {
|
||||
existing.fullName = fullName;
|
||||
}
|
||||
if (logoClass) {
|
||||
existing.logoClass = logoClass;
|
||||
}
|
||||
|
||||
const values = [
|
||||
code,
|
||||
name,
|
||||
fullName,
|
||||
logoClass,
|
||||
...aliases,
|
||||
removeCityPrefix(name),
|
||||
removeCityPrefix(fullName),
|
||||
extractEnglishFragment(name),
|
||||
extractEnglishFragment(fullName),
|
||||
].filter(Boolean);
|
||||
|
||||
for (const value of values) {
|
||||
existing.aliasTokens.add(normalizeToken(value));
|
||||
}
|
||||
|
||||
directory.set(code, existing);
|
||||
}
|
||||
|
||||
function resolveTeamQuery(query, teamDirectory) {
|
||||
const input = String(query || "").trim();
|
||||
const token = normalizeToken(input);
|
||||
const exact = [];
|
||||
const fuzzy = [];
|
||||
|
||||
for (const team of teamDirectory.values()) {
|
||||
if (team.aliasTokens.has(token)) {
|
||||
exact.push(team);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const alias of team.aliasTokens) {
|
||||
if (alias.includes(token) || token.includes(alias)) {
|
||||
fuzzy.push(team);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const matches = exact.length ? exact : fuzzy;
|
||||
if (matches.length === 1) {
|
||||
return {
|
||||
...matches[0],
|
||||
input,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: null,
|
||||
name: input,
|
||||
fullName: input,
|
||||
input,
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
function itemMatchesRequestedTeam(item, requestedTeam) {
|
||||
if (requestedTeam.code) {
|
||||
return item.tcodeH === requestedTeam.code || item.tcodeA === requestedTeam.code;
|
||||
}
|
||||
|
||||
return [
|
||||
normalizeToken(item.tnameH),
|
||||
normalizeToken(item.tnameFH),
|
||||
normalizeToken(item.tnameA),
|
||||
normalizeToken(item.tnameFA),
|
||||
].some((token) => token && (token.includes(requestedTeam.token) || requestedTeam.token.includes(token)));
|
||||
}
|
||||
|
||||
function normalizeMatchStatus(item) {
|
||||
if (Number(item.isEnded) === 1) {
|
||||
return {
|
||||
...STATUS_MAP.finished,
|
||||
finished: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (Number(item.isStarted) === 1) {
|
||||
return {
|
||||
...STATUS_MAP.live,
|
||||
finished: false,
|
||||
quarter: item.playingQuarter || null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...STATUS_MAP.scheduled,
|
||||
finished: false,
|
||||
};
|
||||
}
|
||||
|
||||
function determineWinner(score, status, homeTeam, awayTeam) {
|
||||
if (!status.finished) {
|
||||
return null;
|
||||
}
|
||||
if (score.home === score.away) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
team: score.home > score.away ? homeTeam : awayTeam,
|
||||
};
|
||||
}
|
||||
|
||||
function getTeam(code, name, fullName, logoClass, teamDirectory) {
|
||||
const team = teamDirectory.get(String(code)) || {};
|
||||
return {
|
||||
code: String(code),
|
||||
name: name || team.name || String(code),
|
||||
fullName: fullName || team.fullName || name || String(code),
|
||||
logoClass: logoClass || team.logoClass || null,
|
||||
aliasTokens: team.aliasTokens || new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
function stripAliasTokens(team) {
|
||||
const { aliasTokens, ...rest } = team;
|
||||
return rest;
|
||||
}
|
||||
|
||||
function normalizeToken(value) {
|
||||
return String(value || "")
|
||||
.normalize("NFKC")
|
||||
.toUpperCase()
|
||||
.replace(/[^0-9A-Z가-힣]+/g, "");
|
||||
}
|
||||
|
||||
function buildDateParts(year, month, day) {
|
||||
const paddedMonth = String(month).padStart(2, "0");
|
||||
const paddedDay = String(day).padStart(2, "0");
|
||||
return {
|
||||
isoDate: `${year}-${paddedMonth}-${paddedDay}`,
|
||||
compactDate: `${year}${paddedMonth}${paddedDay}`,
|
||||
year: String(year),
|
||||
month: paddedMonth,
|
||||
day: paddedDay,
|
||||
};
|
||||
}
|
||||
|
||||
function isValidCalendarDate(year, month, day) {
|
||||
const numericYear = Number(year);
|
||||
const numericMonth = Number(month);
|
||||
const numericDay = Number(day);
|
||||
|
||||
if (!Number.isInteger(numericYear) || !Number.isInteger(numericMonth) || !Number.isInteger(numericDay)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (numericMonth < 1 || numericMonth > 12 || numericDay < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const maxDay = [31, isLeapYear(numericYear) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][numericMonth - 1];
|
||||
return numericDay <= maxDay;
|
||||
}
|
||||
|
||||
function isLeapYear(year) {
|
||||
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
||||
}
|
||||
|
||||
function normalizeNumber(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = Number(value);
|
||||
return Number.isFinite(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function compactDateToIso(value) {
|
||||
const input = String(value || "");
|
||||
if (!/^\d{8}$/.test(input)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${input.slice(0, 4)}-${input.slice(4, 6)}-${input.slice(6, 8)}`;
|
||||
}
|
||||
|
||||
function compactTimeToClock(value) {
|
||||
const input = String(value || "");
|
||||
if (!/^\d{4}$/.test(input)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${input.slice(0, 2)}:${input.slice(2, 4)}`;
|
||||
}
|
||||
|
||||
function splitBroadcastChannels(value) {
|
||||
return String(value || "")
|
||||
.split("/")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function compareMatches(left, right) {
|
||||
const leftSortKey = `${left.date || ""}${(left.startTime || "").replace(":", "")}${String(left.gameNumber || "").padStart(4, "0")}`;
|
||||
const rightSortKey = `${right.date || ""}${(right.startTime || "").replace(":", "")}${String(right.gameNumber || "").padStart(4, "0")}`;
|
||||
return leftSortKey.localeCompare(rightSortKey);
|
||||
}
|
||||
|
||||
function removeCityPrefix(value) {
|
||||
const parts = String(value || "").trim().split(/\s+/);
|
||||
return parts.length >= 2 ? parts.slice(1).join(" ") : value;
|
||||
}
|
||||
|
||||
function extractEnglishFragment(value) {
|
||||
const matches = String(value || "").match(/[A-Za-z]{2,}/g);
|
||||
return matches ? matches.join(" ") : null;
|
||||
}
|
||||
|
||||
function calculateWinningPercentage(win, loss, draw) {
|
||||
const total = win + loss + draw;
|
||||
if (!total) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Number((win / total).toFixed(3));
|
||||
}
|
||||
|
||||
function normalizeLastRecord(value) {
|
||||
return Array.isArray(value)
|
||||
? value.slice(0, 5).map((entry) => (Number(entry) === 1 ? "W" : "L"))
|
||||
: [];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildTeamDirectory,
|
||||
normalizeDateInput,
|
||||
normalizeScheduleResponse,
|
||||
normalizeStandingsResponse,
|
||||
normalizeToken,
|
||||
resolveTeamQuery,
|
||||
};
|
||||
1256
packages/kbl-results/test/fixtures/schedule-kbl-2026-04.json
vendored
Normal file
1256
packages/kbl-results/test/fixtures/schedule-kbl-2026-04.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
262
packages/kbl-results/test/fixtures/standings-kbl-2026.json
vendored
Normal file
262
packages/kbl-results/test/fixtures/standings-kbl-2026.json
vendored
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
[
|
||||
{
|
||||
"rank": 1,
|
||||
"tcode": "50",
|
||||
"win": 36,
|
||||
"loss": 18,
|
||||
"draw": 0,
|
||||
"contiWin": 0,
|
||||
"contiLoss": 2,
|
||||
"winDiff": 0.0,
|
||||
"maxWin": 4,
|
||||
"maxLoss": 2,
|
||||
"tname": "창원 LG",
|
||||
"tnameF": "창원 LG 세이커스",
|
||||
"teamLogoClass": "lg",
|
||||
"hwin": 18,
|
||||
"hloss": 9,
|
||||
"awin": 18,
|
||||
"aloss": 9,
|
||||
"lastRecord": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"rank": 2,
|
||||
"tcode": "70",
|
||||
"win": 35,
|
||||
"loss": 19,
|
||||
"draw": 0,
|
||||
"contiWin": 1,
|
||||
"contiLoss": 0,
|
||||
"winDiff": 1.0,
|
||||
"maxWin": 5,
|
||||
"maxLoss": 2,
|
||||
"tname": "안양 정관장",
|
||||
"tnameF": "안양 정관장 레드부스터스",
|
||||
"teamLogoClass": "kgc",
|
||||
"hwin": 20,
|
||||
"hloss": 7,
|
||||
"awin": 15,
|
||||
"aloss": 12,
|
||||
"lastRecord": [
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"rank": 3,
|
||||
"tcode": "16",
|
||||
"win": 33,
|
||||
"loss": 21,
|
||||
"draw": 0,
|
||||
"contiWin": 4,
|
||||
"contiLoss": 0,
|
||||
"winDiff": 3.0,
|
||||
"maxWin": 7,
|
||||
"maxLoss": 3,
|
||||
"tname": "원주 DB",
|
||||
"tnameF": "원주 DB 프로미",
|
||||
"teamLogoClass": "db",
|
||||
"hwin": 17,
|
||||
"hloss": 10,
|
||||
"awin": 16,
|
||||
"aloss": 11,
|
||||
"lastRecord": [
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"rank": 4,
|
||||
"tcode": "55",
|
||||
"win": 32,
|
||||
"loss": 22,
|
||||
"draw": 0,
|
||||
"contiWin": 0,
|
||||
"contiLoss": 2,
|
||||
"winDiff": 4.0,
|
||||
"maxWin": 5,
|
||||
"maxLoss": 4,
|
||||
"tname": "서울 SK",
|
||||
"tnameF": "서울 SK 나이츠",
|
||||
"teamLogoClass": "sk",
|
||||
"hwin": 18,
|
||||
"hloss": 9,
|
||||
"awin": 14,
|
||||
"aloss": 13,
|
||||
"lastRecord": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"rank": 5,
|
||||
"tcode": "66",
|
||||
"win": 28,
|
||||
"loss": 26,
|
||||
"draw": 0,
|
||||
"contiWin": 0,
|
||||
"contiLoss": 1,
|
||||
"winDiff": 8.0,
|
||||
"maxWin": 10,
|
||||
"maxLoss": 4,
|
||||
"tname": "고양 소노",
|
||||
"tnameF": "고양 소노 스카이거너스",
|
||||
"teamLogoClass": "sono",
|
||||
"hwin": 15,
|
||||
"hloss": 12,
|
||||
"awin": 13,
|
||||
"aloss": 14,
|
||||
"lastRecord": [
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"rank": 6,
|
||||
"tcode": "60",
|
||||
"win": 28,
|
||||
"loss": 26,
|
||||
"draw": 0,
|
||||
"contiWin": 0,
|
||||
"contiLoss": 1,
|
||||
"winDiff": 8.0,
|
||||
"maxWin": 7,
|
||||
"maxLoss": 6,
|
||||
"tname": "부산 KCC",
|
||||
"tnameF": "부산 KCC 이지스",
|
||||
"teamLogoClass": "kcc",
|
||||
"hwin": 15,
|
||||
"hloss": 12,
|
||||
"awin": 13,
|
||||
"aloss": 14,
|
||||
"lastRecord": [
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"rank": 7,
|
||||
"tcode": "06",
|
||||
"win": 27,
|
||||
"loss": 27,
|
||||
"draw": 0,
|
||||
"contiWin": 2,
|
||||
"contiLoss": 0,
|
||||
"winDiff": 9.0,
|
||||
"maxWin": 4,
|
||||
"maxLoss": 3,
|
||||
"tname": "수원 KT",
|
||||
"tnameF": "수원 KT 소닉붐",
|
||||
"teamLogoClass": "kt",
|
||||
"hwin": 15,
|
||||
"hloss": 12,
|
||||
"awin": 12,
|
||||
"aloss": 15,
|
||||
"lastRecord": [
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"rank": 8,
|
||||
"tcode": "10",
|
||||
"win": 18,
|
||||
"loss": 36,
|
||||
"draw": 0,
|
||||
"contiWin": 1,
|
||||
"contiLoss": 0,
|
||||
"winDiff": 18.0,
|
||||
"maxWin": 3,
|
||||
"maxLoss": 7,
|
||||
"tname": "울산 현대모비스",
|
||||
"tnameF": "울산 현대모비스 피버스",
|
||||
"teamLogoClass": "hd",
|
||||
"hwin": 11,
|
||||
"hloss": 16,
|
||||
"awin": 7,
|
||||
"aloss": 20,
|
||||
"lastRecord": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"rank": 9,
|
||||
"tcode": "64",
|
||||
"win": 17,
|
||||
"loss": 37,
|
||||
"draw": 0,
|
||||
"contiWin": 1,
|
||||
"contiLoss": 0,
|
||||
"winDiff": 19.0,
|
||||
"maxWin": 2,
|
||||
"maxLoss": 8,
|
||||
"tname": "대구 한국가스공사",
|
||||
"tnameF": "대구 한국가스공사 페가수스",
|
||||
"teamLogoClass": "pega",
|
||||
"hwin": 10,
|
||||
"hloss": 16,
|
||||
"awin": 6,
|
||||
"aloss": 21,
|
||||
"lastRecord": [
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"rank": 10,
|
||||
"tcode": "35",
|
||||
"win": 16,
|
||||
"loss": 38,
|
||||
"draw": 0,
|
||||
"contiWin": 0,
|
||||
"contiLoss": 1,
|
||||
"winDiff": 20.0,
|
||||
"maxWin": 3,
|
||||
"maxLoss": 8,
|
||||
"tname": "서울 삼성",
|
||||
"tnameF": "서울 삼성 썬더스",
|
||||
"teamLogoClass": "ss",
|
||||
"hwin": 8,
|
||||
"hloss": 19,
|
||||
"awin": 8,
|
||||
"aloss": 19,
|
||||
"lastRecord": [
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
]
|
||||
}
|
||||
]
|
||||
181
packages/kbl-results/test/index.test.js
Normal file
181
packages/kbl-results/test/index.test.js
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const {
|
||||
getKBLSummary,
|
||||
getMatchResults,
|
||||
getStandings,
|
||||
} = require("../src/index");
|
||||
const {
|
||||
normalizeDateInput,
|
||||
normalizeScheduleResponse,
|
||||
normalizeStandingsResponse,
|
||||
} = require("../src/parse");
|
||||
|
||||
const fixturesDir = path.join(__dirname, "fixtures");
|
||||
const schedulePayload = JSON.parse(
|
||||
fs.readFileSync(path.join(fixturesDir, "schedule-kbl-2026-04.json"), "utf8"),
|
||||
);
|
||||
const standingsPayload = JSON.parse(
|
||||
fs.readFileSync(path.join(fixturesDir, "standings-kbl-2026.json"), "utf8"),
|
||||
);
|
||||
|
||||
test("normalizeDateInput accepts YYYY-MM-DD and Date inputs", () => {
|
||||
assert.equal(normalizeDateInput("2026-04-01").isoDate, "2026-04-01");
|
||||
assert.equal(
|
||||
normalizeDateInput(new Date("2026-04-01T03:00:00Z")).isoDate,
|
||||
"2026-04-01",
|
||||
);
|
||||
assert.throws(() => normalizeDateInput("2026-13-40"), /date must be a valid Date or YYYY-MM-DD string\./);
|
||||
});
|
||||
|
||||
test("normalizeScheduleResponse filters official KBL schedule data by date and team alias", () => {
|
||||
const result = normalizeScheduleResponse(schedulePayload, {
|
||||
date: "2026-04-01",
|
||||
team: "KCC",
|
||||
});
|
||||
|
||||
assert.equal(result.queryDate, "2026-04-01");
|
||||
assert.equal(result.matches.length, 1);
|
||||
assert.equal(result.matches[0].competitionName, "2025-2026");
|
||||
assert.equal(result.matches[0].seasonCategory.code, "R");
|
||||
assert.equal(result.matches[0].status.code, "ENDED");
|
||||
assert.equal(result.matches[0].status.label, "종료");
|
||||
assert.equal(result.matches[0].homeTeam.code, "60");
|
||||
assert.equal(result.matches[0].homeTeam.name, "부산 KCC");
|
||||
assert.equal(result.matches[0].homeTeam.fullName, "부산 KCC 이지스");
|
||||
assert.equal(result.matches[0].awayTeam.code, "55");
|
||||
assert.deepEqual(result.matches[0].score, { home: 81, away: 79 });
|
||||
assert.equal(result.matches[0].winner.team.code, "60");
|
||||
assert.equal(result.matches[0].venue.name, "부산사직체육관");
|
||||
assert.deepEqual(result.matches[0].broadcastChannels, ["tvN SPORTS"]);
|
||||
assert.equal(result.filteredTeam.code, "60");
|
||||
assert.equal(result.filteredTeam.normalized, "부산 KCC 이지스");
|
||||
});
|
||||
|
||||
test("normalizeStandingsResponse keeps the official KBL team table shape", () => {
|
||||
const standings = normalizeStandingsResponse(standingsPayload);
|
||||
const sk = standings.rows.find((row) => row.team.code === "55");
|
||||
|
||||
assert.equal(standings.rows.length, 10);
|
||||
assert.equal(standings.rows[0].team.code, "50");
|
||||
assert.equal(sk.rank, 4);
|
||||
assert.equal(sk.team.name, "서울 SK");
|
||||
assert.equal(sk.team.fullName, "서울 SK 나이츠");
|
||||
assert.equal(sk.win, 32);
|
||||
assert.equal(sk.loss, 22);
|
||||
assert.equal(sk.gamesBehind, 4);
|
||||
assert.deepEqual(sk.lastFive, ["L", "L", "W", "L", "L"]);
|
||||
});
|
||||
|
||||
test("public fetchers compose day results with current standings via mocked fetch", async () => {
|
||||
const originalFetch = global.fetch;
|
||||
const calls = [];
|
||||
|
||||
global.fetch = async (url, options = {}) => {
|
||||
const target = String(url);
|
||||
calls.push({
|
||||
target,
|
||||
method: options.method || "GET",
|
||||
headers: options.headers || {},
|
||||
});
|
||||
|
||||
if (target.includes("/match/list?")) {
|
||||
return makeResponse(schedulePayload);
|
||||
}
|
||||
|
||||
if (target.endsWith("/league/rank/team")) {
|
||||
return makeResponse(standingsPayload);
|
||||
}
|
||||
|
||||
throw new Error(`unexpected url: ${target}`);
|
||||
};
|
||||
|
||||
try {
|
||||
const matches = await getMatchResults("2026-04-01", { team: "서울 SK" });
|
||||
assert.equal(matches.matches.length, 1);
|
||||
assert.equal(matches.matches[0].awayTeam.fullName, "서울 SK 나이츠");
|
||||
|
||||
const standings = await getStandings();
|
||||
assert.equal(standings.rows[0].team.fullName, "창원 LG 세이커스");
|
||||
|
||||
const summary = await getKBLSummary("2026-04-01", {
|
||||
team: "KCC",
|
||||
includeStandings: true,
|
||||
});
|
||||
|
||||
assert.equal(summary.matches.length, 1);
|
||||
assert.equal(summary.standings.rows[0].rank, 1);
|
||||
assert.equal(summary.standings.rows[0].team.fullName, "창원 LG 세이커스");
|
||||
assert.ok(
|
||||
calls.some((call) => call.target.includes("fromDate=20260401")),
|
||||
"expected official date params in the live schedule request",
|
||||
);
|
||||
assert.ok(
|
||||
calls.every((call) => call.headers["accept-language"]?.includes("ko-KR")),
|
||||
"expected live requests to pin Korean-language payloads",
|
||||
);
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("getKBLSummary skips the standings endpoint when includeStandings is false", async () => {
|
||||
const originalFetch = global.fetch;
|
||||
const calls = [];
|
||||
|
||||
global.fetch = async (url) => {
|
||||
const target = String(url);
|
||||
calls.push(target);
|
||||
|
||||
if (target.includes("/match/list?")) {
|
||||
return makeResponse(schedulePayload);
|
||||
}
|
||||
|
||||
throw new Error(`unexpected url: ${target}`);
|
||||
};
|
||||
|
||||
try {
|
||||
const summary = await getKBLSummary("2026-04-01", {
|
||||
team: "KCC",
|
||||
includeStandings: false,
|
||||
});
|
||||
|
||||
assert.equal(summary.matches.length, 1);
|
||||
assert.equal(summary.standings, undefined);
|
||||
assert.deepEqual(
|
||||
calls.filter((target) => target.includes("/league/rank/team")),
|
||||
[],
|
||||
);
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("getMatchResults rejects impossible calendar dates before fetching", async () => {
|
||||
let fetchCalled = false;
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
getMatchResults("2026-13-40", {
|
||||
fetchImpl: async () => {
|
||||
fetchCalled = true;
|
||||
return makeResponse(schedulePayload);
|
||||
},
|
||||
}),
|
||||
/date must be a valid Date or YYYY-MM-DD string\./,
|
||||
);
|
||||
|
||||
assert.equal(fetchCalled, false);
|
||||
});
|
||||
|
||||
function makeResponse(body) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -254,7 +254,7 @@ test("repository docs advertise the used-car-price-search skill", () => {
|
|||
assert.match(install, /--skill used-car-price-search/);
|
||||
assert.match(
|
||||
install,
|
||||
/npm install -g kordoc pdfjs-dist kbo-game kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby public-restroom-nearby korean-law-mcp/,
|
||||
/npm install -g kordoc pdfjs-dist kbo-game kbl-results kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby public-restroom-nearby korean-law-mcp/,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1185,10 +1185,69 @@ test("root pack:dry-run script covers all publishable workspaces", () => {
|
|||
assert.match(packageJson.scripts["pack:dry-run"], /workspace blue-ribbon-nearby/);
|
||||
assert.match(packageJson.scripts["pack:dry-run"], /workspace kakao-bar-nearby/);
|
||||
assert.match(packageJson.scripts["pack:dry-run"], /workspace public-restroom-nearby/);
|
||||
assert.match(packageJson.scripts["pack:dry-run"], /workspace kbl-results/);
|
||||
assert.match(packageJson.scripts["pack:dry-run"], /workspace kleague-results/);
|
||||
assert.match(packageJson.scripts["pack:dry-run"], /workspace lck-analytics/);
|
||||
});
|
||||
|
||||
test("repository docs advertise the kbl-results skill across the documented surfaces", () => {
|
||||
const readme = read("README.md");
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
const roadmap = read(path.join("docs", "roadmap.md"));
|
||||
const sources = read(path.join("docs", "sources.md"));
|
||||
const featureDocPath = path.join(repoRoot, "docs", "features", "kbl-results.md");
|
||||
const skillPath = path.join(repoRoot, "kbl-results", "SKILL.md");
|
||||
|
||||
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/kbl-results.md to exist");
|
||||
assert.ok(fs.existsSync(skillPath), "expected kbl-results/SKILL.md to exist");
|
||||
assert.match(readme, /\| KBL 경기 결과 조회 \|/);
|
||||
assert.match(readme, /\[KBL 경기 결과 가이드\]\(docs\/features\/kbl-results\.md\)/);
|
||||
assert.match(install, /--skill kbl-results/);
|
||||
assert.match(roadmap, /KBL 경기 결과 조회 스킬 출시/);
|
||||
assert.match(sources, /KBL 일정\/결과 API: https:\/\/api\.kbl\.or\.kr\/match\/list/);
|
||||
assert.match(sources, /KBL 팀 순위 API: https:\/\/api\.kbl\.or\.kr\/league\/rank\/team/);
|
||||
});
|
||||
|
||||
test("kbl-results skill documents the official JSON flow for date, team, and standings lookups", () => {
|
||||
const skillPath = path.join(repoRoot, "kbl-results", "SKILL.md");
|
||||
|
||||
assert.ok(fs.existsSync(skillPath), "expected kbl-results/SKILL.md to exist");
|
||||
|
||||
const skill = read(path.join("kbl-results", "SKILL.md"));
|
||||
const featureDoc = read(path.join("docs", "features", "kbl-results.md"));
|
||||
|
||||
assert.match(skill, /^name: kbl-results$/m);
|
||||
assert.match(skill, /^description: .*KBL.*경기 결과.*순위.*$/m);
|
||||
|
||||
for (const doc of [skill, featureDoc]) {
|
||||
assert.match(doc, /YYYY-MM-DD/);
|
||||
assert.match(doc, /서울 SK|부산 KCC|팀 코드/);
|
||||
assert.match(doc, /https:\/\/api\.kbl\.or\.kr\/match\/list/);
|
||||
assert.match(doc, /https:\/\/api\.kbl\.or\.kr\/league\/rank\/team/);
|
||||
assert.match(doc, /공식 JSON|공식 API|공식 표면/u);
|
||||
assert.match(doc, /현재 순위|standings/i);
|
||||
assert.match(doc, /kbl-results|KBL 경기 결과/u);
|
||||
}
|
||||
});
|
||||
|
||||
test("kbl-results package exports reusable results and standings helpers", () => {
|
||||
const pkg = require(path.join(repoRoot, "packages", "kbl-results", "src", "index.js"));
|
||||
|
||||
assert.equal(typeof pkg.getMatchResults, "function");
|
||||
assert.equal(typeof pkg.getStandings, "function");
|
||||
assert.equal(typeof pkg.getKBLSummary, "function");
|
||||
});
|
||||
|
||||
test("kbl-results package README stays aligned with the official KBL JSON lookup flow", () => {
|
||||
const packageReadme = read(path.join("packages", "kbl-results", "README.md"));
|
||||
|
||||
assert.match(packageReadme, /공식 KBL JSON 엔드포인트/u);
|
||||
assert.match(packageReadme, /api\.kbl\.or\.kr\/match\/list/);
|
||||
assert.match(packageReadme, /league\/rank\/team/);
|
||||
assert.match(packageReadme, /getKBLSummary/);
|
||||
assert.match(packageReadme, /서울 SK/);
|
||||
});
|
||||
|
||||
test("repository docs advertise the kleague-results skill across the documented surfaces", () => {
|
||||
const readme = read("README.md");
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue