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:
Jeffrey (Dongkyu) Kim 2026-04-18 11:20:42 +09:00 committed by GitHub
commit 7c0bfa4c93
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 2595 additions and 3 deletions

View file

@ -0,0 +1,5 @@
---
"kbl-results": minor
---
Add a reusable KBL results and standings package backed by the official JSON APIs.

View file

@ -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)

View 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 은 기본 경로가 아니다.

View file

@ -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)"
```

View file

@ -7,6 +7,7 @@
- SRT
- KTX
- KBO 경기 결과
- KBL 경기 결과 조회 스킬 출시
- K리그 경기 결과 조회 스킬 출시
- LCK 경기 분석 스킬 출시
- 토스증권 조회 스킬 출시

View file

@ -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
View 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
View file

@ -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",

View file

@ -12,7 +12,7 @@
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js && python3 -m py_compile scripts/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",

View file

@ -0,0 +1,5 @@
# kbl-results
## 0.1.0
- Initial release.

View 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` 입니다.

View 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"
}
}

View 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,
};

View 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,
};

File diff suppressed because it is too large Load diff

View 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
]
}
]

View 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",
},
});
}

View file

@ -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"));