mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
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:
parent
8b36634e28
commit
83d5f26b39
17 changed files with 4274 additions and 2 deletions
5
.changeset/bright-dodos-wave.md
Normal file
5
.changeset/bright-dodos-wave.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"kleague-results": minor
|
||||
---
|
||||
|
||||
Add the first official K League results and standings client package.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
62
docs/features/kleague-results.md
Normal file
62
docs/features/kleague-results.md
Normal 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 를 요청해야 한다.
|
||||
|
|
@ -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)"
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
- SRT
|
||||
- KTX
|
||||
- KBO 경기 결과
|
||||
- K리그 경기 결과 조회 스킬 출시
|
||||
- 로또 당첨번호
|
||||
- 서울 지하철 도착 정보
|
||||
- 사용자 위치 미세먼지 조회 스킬 출시
|
||||
|
|
|
|||
|
|
@ -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
102
kleague-results/SKILL.md
Normal 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
11
package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
63
packages/kleague-results/README.md
Normal file
63
packages/kleague-results/README.md
Normal 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` 기준 현재 순위를 조회합니다.
|
||||
31
packages/kleague-results/package.json
Normal file
31
packages/kleague-results/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
139
packages/kleague-results/src/index.js
Normal file
139
packages/kleague-results/src/index.js
Normal 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,
|
||||
};
|
||||
362
packages/kleague-results/src/parse.js
Normal file
362
packages/kleague-results/src/parse.js
Normal 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,
|
||||
};
|
||||
2195
packages/kleague-results/test/fixtures/schedule-kleague1-2026-03.json
vendored
Normal file
2195
packages/kleague-results/test/fixtures/schedule-kleague1-2026-03.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1102
packages/kleague-results/test/fixtures/standings-kleague1-2026.json
vendored
Normal file
1102
packages/kleague-results/test/fixtures/standings-kleague1-2026.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
136
packages/kleague-results/test/index.test.js
Normal file
136
packages/kleague-results/test/index.test.js
Normal 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"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue