mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Compare commits
2 commits
main
...
feature/#1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f08fc576ef | ||
|
|
a7aeec072e |
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