Add an official K League results client and skill

The K League site already exposes JSON schedule and standings endpoints, so this change wraps those official surfaces in a reusable workspace package and wires the new skill/docs flow into the repo.

The implementation keeps the fetch/parse boundary small, locks normalization with fixtures and regression tests, and documents the publish follow-up needed for the new npm package. Korean-language request headers are pinned so live payloads keep the expected team names and result labels.

Constraint: Must use official K League surfaces instead of adding scraping or third-party dependencies
Rejected: HTML scraping from schedule pages | official JSON endpoints already provide schedule and standings data
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep the accept-language header pinned to Korean unless team alias normalization is expanded for English payloads
Tested: npm run ci; live getKLeagueSummary('2026-03-22', { leagueId: 'K리그1', team: 'FC서울', includeStandings: true }); live getMatchResults('2026-03-22', { leagueId: 'K리그2' })
Not-tested: Live in-progress/postponed match statuses beyond the fixture-covered finished-game path
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-03-29 15:57:15 +09:00
commit 83d5f26b39
17 changed files with 4274 additions and 2 deletions

View file

@ -0,0 +1,5 @@
---
"kleague-results": minor
---
Add the first official K League results and standings client package.

View file

@ -24,6 +24,7 @@ Claude code, codex, opencode 등 각종 코딩 에이전트 지원합니다.
| 서울 지하철 도착정보 조회 | 역 기준 실시간 도착 예정 열차 확인 | 필요 | [서울 지하철 도착정보 가이드](docs/features/seoul-subway-arrival.md) |
| 사용자 위치 미세먼지 조회 | `k-skill-proxy` 로 현재 위치 또는 지역 fallback 기준 PM10/PM2.5 확인 | 불필요 | [사용자 위치 미세먼지 조회 가이드](docs/features/fine-dust-location.md) |
| KBO 경기 결과 조회 | 날짜별 경기 일정, 결과, 팀별 필터링 | 불필요 | [KBO 결과 가이드](docs/features/kbo-results.md) |
| K리그 경기 결과 조회 | 날짜별 K리그1/K리그2 경기 결과, 팀별 필터링, 현재 순위 확인 | 불필요 | [K리그 결과 가이드](docs/features/kleague-results.md) |
| 로또 당첨 확인 | 최신 회차, 특정 회차, 번호 대조 | 불필요 | [로또 결과 가이드](docs/features/lotto-results.md) |
| HWP 문서 처리 | `.hwp` → JSON/Markdown/HTML 변환, 이미지 추출, 배치 처리, Windows 직접 제어 선택 | 불필요 | [HWP 문서 처리 가이드](docs/features/hwp.md) |
| 근처 블루리본 맛집 | 현재 위치를 먼저 확인한 뒤 블루리본 서베이 공식 표면으로 근처 블루리본 맛집 검색 | 불필요 | [근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md) |
@ -60,6 +61,7 @@ Claude code, codex, opencode 등 각종 코딩 에이전트 지원합니다.
- [서울 지하철 도착정보 조회](docs/features/seoul-subway-arrival.md)
- [사용자 위치 미세먼지 조회](docs/features/fine-dust-location.md)
- [KBO 경기 결과 조회](docs/features/kbo-results.md)
- [K리그 경기 결과 조회](docs/features/kleague-results.md)
- [로또 당첨 확인](docs/features/lotto-results.md)
- [HWP 문서 처리](docs/features/hwp.md)
- [근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md)

View file

@ -0,0 +1,62 @@
# K리그 결과 가이드
## 이 기능으로 할 수 있는 일
- 날짜별 K리그1 / K리그2 경기 일정 및 결과 조회
- 특정 팀(`FC서울`, `서울 이랜드`, 팀 코드 등) 경기만 필터링
- 현재 순위 확인
## 먼저 필요한 것
- Node.js 18+
- `npm install -g kleague-results`
## 입력값
- 날짜: `YYYY-MM-DD`
- 리그: `K리그1` 또는 `K리그2`
- 선택 사항: 팀명, 풀네임, 팀 코드
## 공식 표면
이 기능은 HTML scraping 대신 공식 JSON 표면을 직접 사용한다.
- K League 일정/결과 JSON: `https://www.kleague.com/getScheduleList.do`
- K League 팀 순위 JSON: `https://www.kleague.com/record/teamRank.do`
## 기본 흐름
1. 패키지가 없으면 다른 방법으로 우회하지 말고 먼저 `kleague-results` 를 전역 설치한다.
2. `getScheduleList.do` 로 해당 월 데이터를 받고 요청한 날짜(`YYYY-MM-DD`)만 정확히 필터링한다.
3. 요청 팀이 있으면 `서울`, `FC서울`, `K09` 같은 alias 를 같은 팀으로 인식해 걸러낸다.
4. `teamRank.do` 로 현재 순위를 가져와 경기 결과와 함께 보여준다.
## 예시
```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, "kleague-results", "src", "index.js"),
).href;
const { getKLeagueSummary } = await import(entry);
const summary = await getKLeagueSummary("2026-03-22", {
leagueId: "K리그1",
team: "FC서울",
includeStandings: true,
});
console.log(JSON.stringify(summary, null, 2));
JS
```
## 주의할 점
- `getScheduleList.do` 는 월 단위 데이터를 주므로 반드시 날짜를 다시 필터링해야 한다.
- 순위는 `stadium=all` 기준 현재 표를 사용한다. 필요하면 추후 홈/원정 표로 확장할 수 있다.
- `서울` 같은 짧은 이름은 리그가 다르면 다른 팀을 뜻할 수 있다. K리그2라면 `서울 이랜드` 여부를 확인한다.
- 경기 종료 전 날짜는 `예정` 또는 `진행 중` 상태가 반환될 수 있다.
- 새 패키지이므로 배포 전까지는 로컬 워크스페이스/pack artifact 로 검증하고, 머지 후 publish 를 요청해야 한다.

View file

@ -46,6 +46,7 @@ k-skill-setup 스킬을 사용해서 공통 설정을 진행해줘.
npx --yes skills add <owner/repo> \
--skill hwp \
--skill kbo-results \
--skill kleague-results \
--skill lotto-results \
--skill kakaotalk-mac \
--skill fine-dust-location \
@ -100,7 +101,7 @@ npm run ci
### Node 패키지
```bash
npm install -g @ohah/hwpjs kbo-game k-lotto
npm install -g @ohah/hwpjs kbo-game kleague-results k-lotto
export NODE_PATH="$(npm root -g)"
```

View file

@ -7,6 +7,7 @@
- SRT
- KTX
- KBO 경기 결과
- K리그 경기 결과 조회 스킬 출시
- 로또 당첨번호
- 서울 지하철 도착 정보
- 사용자 위치 미세먼지 조회 스킬 출시

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
- K League 일정/결과 JSON: https://www.kleague.com/getScheduleList.do
- K League 팀 순위 JSON: https://www.kleague.com/record/teamRank.do
- `@ohah/hwpjs`: https://github.com/ohah/hwpjs
- `hwp-mcp`: https://github.com/jkf87/hwp-mcp
- `silver-flight-group/kakaocli`: https://github.com/silver-flight-group/kakaocli

102
kleague-results/SKILL.md Normal file
View file

@ -0,0 +1,102 @@
---
name: kleague-results
description: 케이리그 경기 결과와 현재 순위를 날짜/팀 기준으로 조회한다. 공식 JSON 엔드포인트와 kleague-results npm 패키지를 사용한다.
license: MIT
metadata:
category: sports
locale: ko-KR
phase: v1
---
# K League Results
## What this skill does
공식 K리그 JSON 표면으로 특정 날짜의 K리그1/K리그2 경기 결과를 조회하고, 필요하면 특정 팀(예: `FC서울`, `서울 이랜드`, 팀 코드 `K09`)만 필터링한 뒤 현재 순위까지 함께 정리한다.
## When to use
- "오늘 K리그1 경기 결과 알려줘"
- "2026-03-22 FC서울 경기 결과랑 현재 순위 보여줘"
- "2026-03-22 K리그2 결과 정리해줘"
## Prerequisites
- Node.js 18+
- `npm install -g kleague-results`
## Inputs
- 날짜: `YYYY-MM-DD`
- 리그: `K리그1` 또는 `K리그2` (기본값은 `K리그1`)
- 선택 사항: 팀명, 풀네임, 팀 코드 (`서울`, `FC서울`, `K09` 등)
## Workflow
### 0. Install the package globally when missing
`npm root -g` 아래에 `kleague-results` 가 없으면 HTML scraping 으로 우회하지 말고 먼저 전역 Node 패키지 설치를 시도한다.
```bash
npm install -g kleague-results
```
### 1. Fetch the official K League JSON
이 스킬은 HTML 크롤링 대신 아래 공식 JSON 엔드포인트를 사용한다.
- 일정/결과: `https://www.kleague.com/getScheduleList.do`
- 팀 순위: `https://www.kleague.com/record/teamRank.do`
```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, "kleague-results", "src", "index.js"),
).href;
const { getKLeagueSummary } = await import(entry);
const summary = await getKLeagueSummary("2026-03-22", {
leagueId: "K리그1",
team: "FC서울",
includeStandings: true,
});
console.log(JSON.stringify(summary, null, 2));
JS
```
### 2. Normalize for humans
원본 JSON을 그대로 던지지 말고 아래 기준으로 정리한다.
- 홈팀 vs 원정팀
- 경기 시간 / 경기 종료 여부
- 스코어
- 현재 순위
- 요청 팀이 있으면 해당 팀 경기만 필터링
### 3. Keep the answer compact
요청이 scoreboard 면 경기별 한 줄 요약부터 준다. 특정 팀 요청이면 그 팀 경기와 현재 순위만 먼저 보여준다.
## Done when
- 날짜 기준 경기 요약이 있다
- 팀 요청이면 해당 팀 경기만 남아 있다
- 현재 순위가 같이 정리되어 있다
## Failure modes
- K리그 사이트가 `getScheduleList.do` 또는 `teamRank.do` 응답 구조를 바꾸면 패키지 수정이 필요하다
- 경기 전 날짜면 결과 대신 예정 상태가 반환될 수 있다
- `서울` 처럼 짧은 이름만 주면 리그에 따라 `FC서울` / `서울 이랜드` 구분이 필요할 수 있다
## Notes
- 이 스킬은 조회 전용이다
- 사용자의 "오늘/어제" 요청은 항상 절대 날짜(`YYYY-MM-DD`)로 변환해서 실행한다
- 패키지를 새로 추가한 상태라면 머지 후 npm publish(Changesets 기반)를 진행해야 전역 설치 흐름이 살아난다
- 자세한 사용 예시는 `docs/features/kleague-results.md``packages/kleague-results/README.md` 를 따른다

11
package-lock.json generated
View file

@ -1151,6 +1151,10 @@
"resolved": "packages/k-skill-proxy",
"link": true
},
"node_modules/kleague-results": {
"resolved": "packages/kleague-results",
"link": true
},
"node_modules/light-my-request": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
@ -1921,6 +1925,13 @@
"engines": {
"node": ">=18"
}
},
"packages/kleague-results": {
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": ">=18"
}
}
}
}

View file

@ -12,7 +12,7 @@
"lint": "node --check scripts/skill-docs.test.js && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"typecheck": "tsc --noEmit",
"test": "node --test scripts/skill-docs.test.js && 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 blue-ribbon-nearby --dry-run",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kleague-results --dry-run",
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
"version-packages": "changeset version",
"release:npm": "changeset publish"

View file

@ -0,0 +1,63 @@
# kleague-results
공식 K리그 JSON 엔드포인트를 감싼 재사용 가능한 Node.js 클라이언트입니다. 날짜별 경기 결과와 현재 순위를 함께 조회할 수 있습니다.
## Install
```bash
npm install kleague-results
```
## Official surfaces
- 일정/결과: `https://www.kleague.com/getScheduleList.do`
- 팀 순위: `https://www.kleague.com/record/teamRank.do`
## Usage
```js
const { getKLeagueSummary, getMatchResults, getStandings } = require("kleague-results");
const results = await getMatchResults("2026-03-22", {
leagueId: "K리그1",
team: "FC서울",
});
const standings = await getStandings({
leagueId: 1,
year: 2026,
});
const summary = await getKLeagueSummary("2026-03-22", {
leagueId: "K리그1",
team: "FC서울",
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.leagueId`: `1`, `2`, `K리그1`, `K리그2`
- `options.team`: short name / full name / team code alias
### `getStandings(options)`
- `options.leagueId`: `1` 또는 `2`
- `options.year`: 시즌 연도, 기본값은 한국 시간 현재 연도
### `getKLeagueSummary(date, options)`
- 날짜 결과와 현재 순위를 한 번에 반환합니다.
## Notes
- 공식 K리그 JSON 엔드포인트 기준이라 HTML 크롤링보다 유지보수가 단순합니다.
- `getScheduleList.do` 는 월 단위 응답이므로 라이브러리가 요청 날짜만 다시 필터링합니다.
- `teamRank.do``stadium=all` 기준 현재 순위를 조회합니다.

View file

@ -0,0 +1,31 @@
{
"name": "kleague-results",
"version": "0.1.0",
"description": "Official K League 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",
"korea",
"kleague",
"soccer"
],
"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,139 @@
const {
normalizeDateInput,
normalizeLeagueId,
normalizeScheduleResponse,
normalizeStandingsResponse,
} = require("./parse");
const SCHEDULE_URL = "https://www.kleague.com/getScheduleList.do";
const TEAM_RANK_URL = "https://www.kleague.com/record/teamRank.do";
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",
"content-type": "application/json; charset=utf-8",
"user-agent": "k-skill/kleague-results",
};
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: options.method || "GET",
headers: {
...DEFAULT_HEADERS,
...(options.headers || {}),
},
body: options.body,
signal: options.signal,
});
if (!response.ok) {
throw new Error(`K League request failed with ${response.status} for ${url}`);
}
return response.json();
}
async function fetchScheduleMonth({ year, month, leagueId, fetchImpl, signal }) {
return requestJson(SCHEDULE_URL, {
method: "POST",
body: JSON.stringify({
year: String(year),
month: String(month).padStart(2, "0"),
leagueId: normalizeLeagueId(leagueId),
}),
fetchImpl,
signal,
});
}
async function fetchStandings({ leagueId, year, stadium = "all", recordType = "rank", fetchImpl, signal }) {
const url = new URL(TEAM_RANK_URL);
url.searchParams.set("leagueId", String(normalizeLeagueId(leagueId)));
url.searchParams.set("year", String(year));
url.searchParams.set("stadium", stadium);
url.searchParams.set("recordType", recordType);
return requestJson(url.toString(), {
method: "POST",
fetchImpl,
signal,
});
}
async function getMatchResults(date, options = {}) {
const queryDate = normalizeDateInput(date);
const leagueId = normalizeLeagueId(options.leagueId);
const payload = options.schedulePayload || await fetchScheduleMonth({
year: queryDate.year,
month: queryDate.month,
leagueId,
fetchImpl: options.fetchImpl,
signal: options.signal,
});
return normalizeScheduleResponse(payload, {
date: queryDate.isoDate,
leagueId,
team: options.team,
});
}
async function getStandings(options = {}) {
const leagueId = normalizeLeagueId(options.leagueId);
const year = Number(options.year || getCurrentKoreaYear());
const payload = options.standingsPayload || await fetchStandings({
leagueId,
year,
fetchImpl: options.fetchImpl,
signal: options.signal,
});
return normalizeStandingsResponse(payload, {
leagueId,
year,
clubs: options.clubs,
});
}
async function getKLeagueSummary(date, options = {}) {
const matches = options.matchesResponse || await getMatchResults(date, options);
const summary = {
queryDate: matches.queryDate,
leagueId: matches.leagueId,
filteredTeam: matches.filteredTeam,
matches: matches.matches,
};
if (options.includeStandings !== false) {
summary.standings = await getStandings({
leagueId: matches.leagueId,
year: Number(matches.queryDate.slice(0, 4)),
clubs: matches.clubs,
fetchImpl: options.fetchImpl,
signal: options.signal,
standingsPayload: options.standingsPayload,
});
}
return summary;
}
function getCurrentKoreaYear() {
return Number(new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Seoul",
year: "numeric",
}).format(new Date()));
}
module.exports = {
fetchScheduleMonth,
fetchStandings,
getKLeagueSummary,
getMatchResults,
getStandings,
};

View file

@ -0,0 +1,362 @@
const LEAGUE_ALIAS_MAP = new Map([
["1", 1],
["K1", 1],
["KLEAGUE1", 1],
["K리그1", 1],
["2", 2],
["K2", 2],
["KLEAGUE2", 2],
["K리그2", 2],
]);
const STATUS_MAP = {
FE: { state: "finished", label: "종료" },
NS: { state: "scheduled", label: "예정" },
LIVE: { state: "live", label: "진행 중" },
IN: { state: "live", label: "진행 중" },
HT: { state: "halftime", label: "하프타임" },
PP: { state: "postponed", label: "연기" },
CAN: { state: "cancelled", label: "취소" },
};
function normalizeLeagueId(value = 1) {
if (value === null || value === undefined || value === "") {
return 1;
}
if (Number.isInteger(value) && (value === 1 || value === 2)) {
return value;
}
const token = normalizeToken(value);
const leagueId = LEAGUE_ALIAS_MAP.get(token);
if (!leagueId) {
throw new Error(`leagueId must resolve to K League 1 or 2. Received: ${value}`);
}
return leagueId;
}
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) {
throw new Error("date must be a valid Date or YYYY-MM-DD string.");
}
return buildDateParts(match[1], match[2], match[3]);
}
function normalizeToken(value) {
return String(value || "")
.normalize("NFKC")
.toUpperCase()
.replace(/[^0-9A-Z가-힣]+/g, "");
}
function buildDateParts(year, month, day) {
return {
year: String(year),
month: String(month).padStart(2, "0"),
day: String(day).padStart(2, "0"),
isoDate: `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`,
dottedDate: `${year}.${String(month).padStart(2, "0")}.${String(day).padStart(2, "0")}`,
};
}
function buildClubDirectory(clubList = []) {
const directory = new Map();
for (const club of clubList) {
const code = club.teamId || club.code;
if (!code) {
continue;
}
const name = club.teamNameShort || club.teamName || club.name || club.teamNameFull || club.fullName || code;
const fullName = club.teamNameFull || club.teamName || club.fullName || club.name || name;
const aliasTokens = new Set(
[code, name, fullName, club.teamName, club.teamNameShort, club.name, club.fullName]
.filter(Boolean)
.map(normalizeToken),
);
directory.set(code, {
code,
name,
fullName,
homepage: club.homepage || null,
leagueId: club.leagueId ?? null,
aliasTokens,
});
}
return directory;
}
function normalizeScheduleResponse(payload, options = {}) {
const data = payload?.data || payload || {};
const clubDirectory = buildClubDirectory(data.clubList || options.clubs || []);
const queryDate = options.date ? normalizeDateInput(options.date) : null;
const leagueId = normalizeLeagueId(options.leagueId ?? data.scheduleList?.[0]?.leagueId ?? data.clubList?.[0]?.leagueId ?? 1);
const requestedTeam = options.team ? resolveTeamQuery(options.team, clubDirectory) : null;
const scheduleList = Array.isArray(data.scheduleList) ? data.scheduleList : [];
const matches = scheduleList
.filter((item) => !queryDate || item.gameDate === queryDate.dottedDate)
.filter((item) => !requestedTeam || itemMatchesRequestedTeam(item, requestedTeam, clubDirectory))
.map((item) => normalizeScheduleItem(item, clubDirectory))
.sort(compareMatches);
return {
queryDate: queryDate?.isoDate ?? null,
leagueId,
filteredTeam: requestedTeam
? {
input: requestedTeam.input,
normalized: requestedTeam.fullName || requestedTeam.name || requestedTeam.input,
code: requestedTeam.code || null,
}
: null,
clubs: [...clubDirectory.values()].map((club) => ({
code: club.code,
name: club.name,
fullName: club.fullName,
homepage: club.homepage,
leagueId: club.leagueId,
})),
matches,
};
}
function normalizeScheduleItem(item, clubDirectory) {
const status = normalizeMatchStatus(item);
const homeTeam = getClub(item.homeTeam, item.homeTeamName, item.leagueId, clubDirectory);
const awayTeam = getClub(item.awayTeam, item.awayTeamName, item.leagueId, clubDirectory);
const score = {
home: normalizeNumber(item.homeGoal),
away: normalizeNumber(item.awayGoal),
};
return {
leagueId: normalizeLeagueId(item.leagueId),
competitionName: item.meetName || null,
round: normalizeNumber(item.roundId),
gameId: normalizeNumber(item.gameId),
date: item.gameDate ? item.gameDate.replace(/\./g, "-") : null,
dateLabel: item.gameDate || null,
kickOff: item.gameTime || null,
status,
homeTeam: stripAliasTokens(homeTeam),
awayTeam: stripAliasTokens(awayTeam),
score,
winner: determineWinner(score, status),
venue: {
shortName: item.fieldName || null,
name: item.fieldNameFull || item.fieldName || null,
},
audience: normalizeNumber(item.audienceQty),
broadcastChannels: splitChannels(item.broadcastName),
matchCenterUrl:
item.gameId && item.meetSeq
? `https://www.kleague.com/match.do?year=${item.year}&leagueId=${item.leagueId}&gameId=${item.gameId}&meetSeq=${item.meetSeq}`
: null,
};
}
function normalizeStandingsResponse(payload, options = {}) {
const data = payload?.data || payload || {};
const leagueId = normalizeLeagueId(options.leagueId ?? data.teamRank?.[0]?.leagueId ?? 1);
const clubDirectory = buildClubDirectory(options.clubs || options.clubList || []);
const seen = new Set();
const rows = [];
for (const item of data.teamRank || []) {
if (item.teamId && seen.has(item.teamId)) {
continue;
}
if (item.teamId) {
seen.add(item.teamId);
}
const team = getClub(item.teamId, item.teamName, leagueId, clubDirectory, item.homepage);
rows.push({
rank: normalizeNumber(item.rank),
team: stripAliasTokens(team),
points: normalizeNumber(item.gainPoint) ?? 0,
played: normalizeNumber(item.gameCount) ?? 0,
win: normalizeNumber(item.winCnt) ?? 0,
draw: normalizeNumber(item.tieCnt) ?? 0,
loss: normalizeNumber(item.lossCnt) ?? 0,
goalsFor: normalizeNumber(item.gainGoal) ?? 0,
goalsAgainst: normalizeNumber(item.lossGoal) ?? 0,
goalDifference: normalizeNumber(item.gapCnt) ?? 0,
form: [item.game01, item.game02, item.game03, item.game04, item.game05, item.game06]
.map((value) => String(value || "").trim())
.filter(Boolean),
homepage: item.homepage || team.homepage || null,
stadium: item.stadium || null,
});
}
rows.sort((left, right) => {
if (left.rank !== right.rank) {
return left.rank - right.rank;
}
if (left.points !== right.points) {
return right.points - left.points;
}
return left.team.name.localeCompare(right.team.name, "ko");
});
return {
leagueId,
year: normalizeNumber(options.year ?? data.year) ?? null,
isSplitRank: Boolean(data.isSplitRank),
notice: String(data.ClubRankMsg || "").trim() || null,
rows,
};
}
function normalizeMatchStatus(item) {
const code = item.gameStatus || (item.endYn === "Y" ? "FE" : "NS");
const mapped = STATUS_MAP[code] || {
state: item.endYn === "Y" ? "finished" : "scheduled",
label: item.endYn === "Y" ? "종료" : "예정",
};
return {
code,
state: mapped.state,
label: mapped.label,
finished: mapped.state === "finished",
};
}
function resolveTeamQuery(query, clubDirectory) {
const input = String(query || "").trim();
const token = normalizeToken(input);
for (const club of clubDirectory.values()) {
if (club.aliasTokens.has(token)) {
return {
...club,
input,
token,
};
}
}
return {
code: null,
name: input,
fullName: input,
input,
token,
};
}
function itemMatchesRequestedTeam(item, requestedTeam, clubDirectory) {
const homeTeam = getClub(item.homeTeam, item.homeTeamName, item.leagueId, clubDirectory);
const awayTeam = getClub(item.awayTeam, item.awayTeamName, item.leagueId, clubDirectory);
return homeTeam.aliasTokens.has(requestedTeam.token) || awayTeam.aliasTokens.has(requestedTeam.token);
}
function getClub(code, fallbackName, leagueId, clubDirectory, homepage = null) {
const existing = clubDirectory.get(code);
if (existing) {
return existing;
}
const aliasTokens = new Set([code, fallbackName].filter(Boolean).map(normalizeToken));
return {
code: code || null,
name: fallbackName || code || null,
fullName: fallbackName || code || null,
homepage,
leagueId: leagueId ?? null,
aliasTokens,
};
}
function compareMatches(left, right) {
const leftKey = `${left.date || ""}T${left.kickOff || "00:00"}`;
const rightKey = `${right.date || ""}T${right.kickOff || "00:00"}`;
if (leftKey !== rightKey) {
return leftKey.localeCompare(rightKey);
}
return (left.gameId ?? 0) - (right.gameId ?? 0);
}
function splitChannels(value) {
return [...new Set(String(value || "")
.split(/\/\/|\|/)
.map((part) => part.trim())
.filter(Boolean))];
}
function determineWinner(score, status) {
if (!status.finished || score.home === null || score.away === null) {
return null;
}
if (score.home === score.away) {
return "draw";
}
return score.home > score.away ? "home" : "away";
}
function normalizeNumber(value) {
if (value === null || value === undefined || value === "") {
return null;
}
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function stripAliasTokens(team) {
return {
code: team.code,
name: team.name,
fullName: team.fullName,
homepage: team.homepage,
leagueId: team.leagueId,
};
}
module.exports = {
buildClubDirectory,
normalizeDateInput,
normalizeLeagueId,
normalizeMatchStatus,
normalizeScheduleResponse,
normalizeStandingsResponse,
};

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,136 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const {
getKLeagueSummary,
getMatchResults,
getStandings
} = require("../src/index");
const {
normalizeLeagueId,
normalizeScheduleResponse,
normalizeStandingsResponse
} = require("../src/parse");
const fixturesDir = path.join(__dirname, "fixtures");
const schedulePayload = JSON.parse(
fs.readFileSync(path.join(fixturesDir, "schedule-kleague1-2026-03.json"), "utf8")
);
const standingsPayload = JSON.parse(
fs.readFileSync(path.join(fixturesDir, "standings-kleague1-2026.json"), "utf8")
);
test("normalizeLeagueId accepts K League numeric and Korean aliases", () => {
assert.equal(normalizeLeagueId(1), 1);
assert.equal(normalizeLeagueId("K리그1"), 1);
assert.equal(normalizeLeagueId("k league 2"), 2);
assert.equal(normalizeLeagueId("kleague2"), 2);
assert.throws(() => normalizeLeagueId("K리그3"), /leagueId/);
});
test("normalizeScheduleResponse filters a date and team alias from the official monthly payload", () => {
const result = normalizeScheduleResponse(schedulePayload, {
date: "2026-03-22",
leagueId: 1,
team: "FC서울"
});
assert.equal(result.queryDate, "2026-03-22");
assert.equal(result.leagueId, 1);
assert.equal(result.matches.length, 1);
assert.equal(result.matches[0].competitionName, "하나은행 K리그1 2026");
assert.equal(result.matches[0].round, 5);
assert.equal(result.matches[0].status.code, "FE");
assert.equal(result.matches[0].status.label, "종료");
assert.equal(result.matches[0].homeTeam.code, "K09");
assert.equal(result.matches[0].homeTeam.name, "서울");
assert.equal(result.matches[0].homeTeam.fullName, "FC서울");
assert.equal(result.matches[0].awayTeam.name, "광주");
assert.deepEqual(result.matches[0].score, { home: 5, away: 0 });
assert.equal(result.matches[0].venue.name, "서울 월드컵 경기장");
assert.equal(result.filteredTeam.normalized, "FC서울");
});
test("normalizeStandingsResponse keeps the official K League table shape", () => {
const table = normalizeStandingsResponse(standingsPayload, { leagueId: 1, year: 2026 });
const seoul = table.rows.find((row) => row.team.code === "K09");
assert.equal(table.leagueId, 1);
assert.equal(table.year, 2026);
assert.equal(table.isSplitRank, false);
assert.equal(table.rows.length, 12);
assert.equal(seoul.rank, 1);
assert.equal(seoul.team.name, "서울");
assert.equal(seoul.points, 12);
assert.equal(seoul.played, 4);
assert.deepEqual(seoul.form.slice(0, 4), ["승", "승", "승", "승"]);
});
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",
body: options.body || null,
headers: options.headers || {},
});
if (target.endsWith("/getScheduleList.do")) {
return makeResponse(schedulePayload);
}
if (target.includes("/record/teamRank.do?leagueId=1&year=2026&stadium=all&recordType=rank")) {
return makeResponse(standingsPayload);
}
throw new Error(`unexpected url: ${target}`);
};
try {
const matches = await getMatchResults("2026-03-22", { leagueId: "K리그1", team: "서울" });
assert.equal(matches.matches.length, 1);
assert.equal(matches.matches[0].homeTeam.fullName, "FC서울");
const standings = await getStandings({ leagueId: 1, year: 2026 });
assert.equal(standings.rows[0].team.name, "서울");
const summary = await getKLeagueSummary("2026-03-22", {
leagueId: 1,
team: "FC서울",
includeStandings: true
});
assert.equal(summary.matches.length, 1);
assert.equal(summary.standings.rows[0].rank, 1);
assert.equal(summary.standings.rows[0].team.fullName, "FC서울");
assert.equal(
calls.filter((call) => call.target.endsWith("/getScheduleList.do")).length,
2
);
assert.ok(
calls.some((call) => call.body && String(call.body).includes('"month":"03"')),
"expected schedule fetch to send the official month payload"
);
assert.ok(
calls.every((call) => call.headers["accept-language"]?.includes("ko-KR")),
"expected live requests to pin Korean-language payloads",
);
} finally {
global.fetch = originalFetch;
}
});
function makeResponse(body) {
return new Response(JSON.stringify(body), {
status: 200,
headers: {
"content-type": "application/json"
}
});
}

View file

@ -552,6 +552,64 @@ test("root pack:dry-run script covers all publishable workspaces", () => {
assert.match(packageJson.scripts["pack:dry-run"], /workspace k-lotto/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace daiso-product-search/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace blue-ribbon-nearby/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace kleague-results/);
});
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"));
const roadmap = read(path.join("docs", "roadmap.md"));
const sources = read(path.join("docs", "sources.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "kleague-results.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/kleague-results.md to exist");
assert.match(readme, /\| K리그 경기 결과 조회 \|/);
assert.match(readme, /\[K리그 결과 가이드\]\(docs\/features\/kleague-results\.md\)/);
assert.match(install, /--skill kleague-results/);
assert.match(roadmap, /K리그 경기 결과 조회 스킬 출시/);
assert.match(sources, /K League 일정\/결과 JSON: https:\/\/www\.kleague\.com\/getScheduleList\.do/);
assert.match(sources, /K League 팀 순위 JSON: https:\/\/www\.kleague\.com\/record\/teamRank\.do/);
});
test("kleague-results skill documents the official JSON flow for date, team, and standings lookups", () => {
const skillPath = path.join(repoRoot, "kleague-results", "SKILL.md");
assert.ok(fs.existsSync(skillPath), "expected kleague-results/SKILL.md to exist");
const skill = read(path.join("kleague-results", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "kleague-results.md"));
assert.match(skill, /^name: kleague-results$/m);
assert.match(skill, /^description: .*케이리그.*경기 결과.*순위.*$/m);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /YYYY-MM-DD/);
assert.match(doc, /K리그1|K리그2/);
assert.match(doc, /FC서울|서울 이랜드|팀 코드/);
assert.match(doc, /https:\/\/www\.kleague\.com\/getScheduleList\.do/);
assert.match(doc, /https:\/\/www\.kleague\.com\/record\/teamRank\.do/);
assert.match(doc, /공식 JSON|공식 API|공식 표면/u);
assert.match(doc, /현재 순위|standings/i);
assert.match(doc, /kleague-results|K리그 결과 조회/u);
}
});
test("kleague-results package exports reusable results and standings helpers", () => {
const pkg = require(path.join(repoRoot, "packages", "kleague-results", "src", "index.js"));
assert.equal(typeof pkg.getMatchResults, "function");
assert.equal(typeof pkg.getStandings, "function");
assert.equal(typeof pkg.getKLeagueSummary, "function");
});
test("kleague-results package README stays aligned with the official K League JSON lookup flow", () => {
const packageReadme = read(path.join("packages", "kleague-results", "README.md"));
assert.match(packageReadme, /공식 K리그 JSON 엔드포인트/u);
assert.match(packageReadme, /getScheduleList\.do/);
assert.match(packageReadme, /teamRank\.do/);
assert.match(packageReadme, /getKLeagueSummary/);
assert.match(packageReadme, /FC서울/);
});
test("repository docs advertise the blue-ribbon-nearby skill across the documented surfaces", () => {