Ship LCK analytics inside k-skill's managed release flow

Adapt jerjangmin's upstream lck-analytics skill/package into a new
workspace and skill pack, wire docs/install/release surfaces, and add
regression fixtures/tests plus script smoke coverage so the feature is
verifiable before publish.

Constraint: Upstream package is not published to npm yet
Constraint: Must preserve attribution to original source and author in shipped docs
Rejected: Keep the upstream lck-results install wording in k-skill | conflicts with repo workspace/package naming
Rejected: Ship only the npm package without the local skill scripts | issue explicitly requested the skill as well
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep Changesets as the only version-bump path for lck-analytics; do not hand-edit versions for release
Tested: node --test packages/lck-analytics/test/index.test.js scripts/skill-docs.test.js
Tested: npm run lint --workspace lck-analytics
Tested: npm test --workspace lck-analytics
Tested: live getLckSummary('2026-04-01', { team: '한화', includeStandings: true })
Tested: node lck-analytics/scripts/sync-oracle.js --csv lck-analytics/samples/oracle-lck-sample.csv --cache .tmp/lck-cache && node lck-analytics/scripts/build-match-report.js --date 2026-04-01 --team 한화 --cache .tmp/lck-cache && node lck-analytics/scripts/analyze-live-game.js --game game-1 --window packages/lck-analytics/test/fixtures/live-window-game-1.json --details packages/lck-analytics/test/fixtures/live-details-game-1.json --cache .tmp/lck-cache
Tested: npm run ci
Tested: npx tsc --noEmit --project /Users/jeffrey/Projects/k-skill/tsconfig.json
Not-tested: Riot live feed behavior for arbitrary future game ids outside the fixture-backed smoke path
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-03 18:05:14 +09:00
commit 9fad8ae045
32 changed files with 3253 additions and 7 deletions

View file

@ -0,0 +1,5 @@
---
"lck-analytics": minor
---
Add the first LCK analytics package and skill pack adapted from jerjangmin's original upstream implementation.

View file

@ -26,6 +26,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 한국 법령 검색 | `korean-law-mcp` 우선 + 장애 시 `법망` fallback으로 법령/조문/판례/유권해석 조회 | 로컬 CLI/MCP면 `LAW_OC` 필요, remote endpoint/법망 fallback은 불필요 | [한국 법령 검색 가이드](docs/features/korean-law-search.md) |
| KBO 경기 결과 조회 | 날짜별 경기 일정, 결과, 팀별 필터링 | 불필요 | [KBO 결과 가이드](docs/features/kbo-results.md) |
| K리그 경기 결과 조회 | 날짜별 K리그1/K리그2 경기 결과, 팀별 필터링, 현재 순위 확인 | 불필요 | [K리그 결과 가이드](docs/features/kleague-results.md) |
| LCK 경기 분석 | 날짜별 LCK 결과, 현재 순위, live turning point, 밴픽 matchup/synergy, patch meta, 팀 파워 레이팅 | 불필요 | [LCK 경기 분석 가이드](docs/features/lck-analytics.md) |
| 토스증권 조회 | `tossctl` 기반 계좌 요약, 포트폴리오, 시세, 주문내역, 관심종목 조회 | 필요 | [토스증권 조회 가이드](docs/features/toss-securities.md) |
| 로또 당첨 확인 | 최신 회차, 특정 회차, 번호 대조 | 불필요 | [로또 결과 가이드](docs/features/lotto-results.md) |
| HWP 문서 처리 | `.hwp` → JSON/Markdown/HTML 변환, 이미지 추출, 배치 처리, Windows 직접 제어 선택 | 불필요 | [HWP 문서 처리 가이드](docs/features/hwp.md) |
@ -68,6 +69,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [한국 법령 검색 가이드](docs/features/korean-law-search.md)
- [KBO 경기 결과 조회](docs/features/kbo-results.md)
- [K리그 경기 결과 조회](docs/features/kleague-results.md)
- [LCK 경기 분석 가이드](docs/features/lck-analytics.md)
- [토스증권 조회 가이드](docs/features/toss-securities.md)
- [로또 당첨 확인](docs/features/lotto-results.md)
- [HWP 문서 처리](docs/features/hwp.md)

View file

@ -0,0 +1,106 @@
# LCK 경기 분석
`lck-analytics` 는 Riot 공식 LoL Esports 표면과 Oracle's Elixir 스타일 historical 데이터셋을 함께 사용해 LCK 경기 결과와 고급 분석을 제공하는 스킬이다.
## Origin / attribution
- Original reference skill: <https://github.com/jerjangmin/share/tree/main/SKILL/lck-analytics>
- Original author: `jerjangmin`
- k-skill adaptation: 이 저장소의 npm workspace / Changesets 배포 흐름에 맞춰 패키지와 문서를 정리한 버전
## 무엇을 할 수 있나
- 날짜별 LCK 경기 결과 조회
- 팀 alias 정규화 (`한화`, `HLE`, `SKT T1`, `DN FREECS`, `광동 프릭스` 등)
- 해당 날짜 기준 현재 순위 조회
- live game 킬 / 골드 / 오브젝트 / participant snapshot 조회
- live timeline 기반 turning point 추정
- Oracle's Elixir 스타일 CSV로부터
- 팀 파워 레이팅
- champion matchup / synergy
- patch meta summary 계산
## 설치
```bash
npm install -g lck-analytics
export NODE_PATH="$(npm root -g)"
```
## 기본 조회 예시
```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, "lck-analytics", "src", "index.js"),
).href;
const { getLckSummary } = await import(entry);
const summary = await getLckSummary("2026-04-01", {
team: "한화",
includeStandings: true,
});
console.log(JSON.stringify(summary, null, 2));
JS
```
## Local pipeline
skill directory 안에는 원본 pack을 따라 local helper script도 포함한다.
- `lck-analytics/scripts/sync-oracle.js`
- `lck-analytics/scripts/build-match-report.js`
- `lck-analytics/scripts/analyze-live-game.js`
- `lck-analytics/samples/oracle-lck-sample.csv`
historical cache 생성:
```bash
node ./lck-analytics/scripts/sync-oracle.js \
--csv ./lck-analytics/samples/oracle-lck-sample.csv
```
날짜별 match analysis 생성:
```bash
node ./lck-analytics/scripts/build-match-report.js \
--date 2026-04-01 \
--team 한화
```
live turning point 분석:
```bash
node ./lck-analytics/scripts/analyze-live-game.js \
--game game-id
```
## Official surfaces
- `https://esports-api.lolesports.com/persisted/gw/getSchedule`
- `https://esports-api.lolesports.com/persisted/gw/getTournamentsForLeague`
- `https://esports-api.lolesports.com/persisted/gw/getStandings`
- `https://esports-api.lolesports.com/persisted/gw/getEventDetails`
- `https://feed.lolesports.com/livestats/v1/window/{gameId}`
- `https://feed.lolesports.com/livestats/v1/details/{gameId}`
- Oracle's Elixir downloads / schema reference: <https://oracleselixir.com/tools/downloads>
## Release / publish note
이 기능은 `packages/lck-analytics` workspace로 추가됐다. 따라서 `main` 에 기능 PR이 merge되면:
1. `.changeset/*.md` 가 Version Packages PR을 생성하고
2. 그 PR merge 뒤
3. npm publish workflow가 `lck-analytics` 패키지를 배포한다
즉, main merge 직후 바로 태그를 수동으로 만들지 말고 기존 Changesets 릴리스 흐름을 따른다.
## Caveats
- Riot web app용 공개 API key fallback은 회전될 수 있으므로, 필요하면 `LOLESPORTS_API_KEY` 환경변수로 override한다
- turning point는 live snapshot 기반 heuristic 이라 VOD/GRID 레벨 정밀 분석과 동일하지 않다
- historical 분석 품질은 Oracle-style row sample 수에 크게 좌우된다

View file

@ -47,6 +47,7 @@ npx --yes skills add <owner/repo> \
--skill hwp \
--skill kbo-results \
--skill kleague-results \
--skill lck-analytics \
--skill toss-securities \
--skill lotto-results \
--skill kakaotalk-mac \
@ -121,7 +122,7 @@ npm run ci
### Node 패키지
```bash
npm install -g @ohah/hwpjs kbo-game kleague-results toss-securities k-lotto coupang-product-search used-car-price-search korean-law-mcp
npm install -g @ohah/hwpjs kbo-game kleague-results lck-analytics toss-securities k-lotto coupang-product-search used-car-price-search korean-law-mcp
export NODE_PATH="$(npm root -g)"
```

View file

@ -8,6 +8,7 @@
- KTX
- KBO 경기 결과
- K리그 경기 결과 조회 스킬 출시
- LCK 경기 분석 스킬 출시
- 토스증권 조회 스킬 출시
- 로또 당첨번호
- 서울 지하철 도착 정보

View file

@ -10,6 +10,14 @@
- tossinvest-cli: https://github.com/JungHoonGhae/tossinvest-cli
- K League 일정/결과 JSON: https://www.kleague.com/getScheduleList.do
- K League 팀 순위 JSON: https://www.kleague.com/record/teamRank.do
- jerjangmin original `lck-analytics` skill pack: https://github.com/jerjangmin/share/tree/main/SKILL/lck-analytics
- Riot LoL Esports schedule API: https://esports-api.lolesports.com/persisted/gw/getSchedule
- Riot LoL Esports tournaments API: https://esports-api.lolesports.com/persisted/gw/getTournamentsForLeague
- Riot LoL Esports standings API: https://esports-api.lolesports.com/persisted/gw/getStandings
- Riot LoL Esports event details API: https://esports-api.lolesports.com/persisted/gw/getEventDetails
- Riot LoL Esports live window feed: https://feed.lolesports.com/livestats/v1/window/<gameId>
- Riot LoL Esports live details feed: https://feed.lolesports.com/livestats/v1/details/<gameId>
- Oracle's Elixir data glossary: https://oracleselixir.com/tools/downloads
- `@ohah/hwpjs`: https://github.com/ohah/hwpjs
- `hwp-mcp`: https://github.com/jkf87/hwp-mcp
- korean-law-mcp: https://github.com/chrisryugj/korean-law-mcp

15
lck-analytics/README.md Normal file
View file

@ -0,0 +1,15 @@
# LCK Analytics skill pack
k-skill 버전의 `lck-analytics` 스킬 팩입니다.
- Original source: <https://github.com/jerjangmin/share/tree/main/SKILL/lck-analytics>
- Original author: `jerjangmin`
- This repo adaptation: npm workspace / Changesets 릴리스 흐름에 맞춘 k-skill 배포용 패키징
포함 항목:
- `SKILL.md`: 에이전트에 바로 줄 수 있는 스킬 문서
- `scripts/sync-oracle.js`: Oracle-style CSV → historical cache JSON
- `scripts/build-match-report.js`: 날짜별 match analysis 생성
- `scripts/analyze-live-game.js`: live game analysis 생성
- `samples/oracle-lck-sample.csv`: local smoke test용 샘플 CSV

202
lck-analytics/SKILL.md Normal file
View file

@ -0,0 +1,202 @@
---
name: lck-analytics
description: Riot 공식 LoL Esports 데이터와 Oracle's Elixir 스타일 historical 데이터로 LCK 경기 결과, 현재 순위, live turning point, 밴픽 matchup/synergy, patch meta, 팀 파워 레이팅을 조회한다.
license: MIT
metadata:
category: sports
locale: ko-KR
phase: v1
---
# LCK Results + Advanced Analysis
## What this skill does
이 스킬은 LCK 조회/분석 전용이다.
- 특정 날짜 LCK 경기 결과 조회
- 특정 팀 alias 정규화 후 필터링
- 현재 스플릿 순위 조회
- 진행 중 경기 live stats 조회
- live timeline 기반 turning point 분석
- Oracle's Elixir 스타일 historical row / CSV 기반
- 팀 파워 레이팅
- 챔피언 matchup / synergy 분석
- patch meta 요약
- 날짜별 match analysis 생성
## Origin / attribution
이 스킬은 `jerjangmin` 님이 만든 원본 [`lck-analytics` skill pack](https://github.com/jerjangmin/share/tree/main/SKILL/lck-analytics)을 k-skill 저장소 안으로 옮기고, 이 저장소의 npm workspace / Changesets 배포 방식에 맞게 정리한 버전이다.
## When to use
- "오늘 LCK 경기 결과 알려줘"
- "2026-04-01 한화 경기 결과랑 순위 보여줘"
- "지금 T1 경기 킬/골드/오브젝트 요약해줘"
- "이 경기 turning point가 뭐였어?"
- "이 밴픽에서 어느 쪽 조합이 더 좋았는지 설명해줘"
- "현재 패치에서 어떤 챔피언이 메타 픽인지 보여줘"
- "LCK 팀 파워 레이팅 보여줘"
## Prerequisites
- Node.js 18+
- `npm install -g lck-analytics`
패키지가 없으면 다른 방법으로 우회하지 말고 먼저 전역 설치를 시도한다.
```bash
npm install -g lck-analytics
```
## Inputs
### 기본 입력
- 날짜: `YYYY-MM-DD`
- 선택 사항: 팀명, 과거 팀명, 한글/영문 약칭 alias
### 고급 분석 입력
- Oracle's Elixir 스타일 CSV 문자열 또는 row 배열
- game id / match id
- live window/details payload 또는 실시간 fetch 권한
- patch version
## Team alias normalization
다음 이름들은 같은 canonical team 으로 인식한다.
- `DN SOOPers`
- `DN FREECS`
- `광동 프릭스`
- `Afreeca Freecs`
추가로 `T1`, `SKT T1`, `담원`, `Dplus KIA`, `브리온`, `한화`, `젠지`, `피어엑스` 등도 alias 정규화를 지원한다.
## Official surfaces
이 스킬은 Riot 공식 / 공식 웹앱 표면을 우선 사용한다.
- 일정/결과: `getSchedule`
- 토너먼트 목록: `getTournamentsForLeague`
- 순위: `getStandings`
- 이벤트 상세: `getEventDetails`
- 라이브 window: `https://feed.lolesports.com/livestats/v1/window/{gameId}`
- 라이브 details: `https://feed.lolesports.com/livestats/v1/details/{gameId}`
historical 고급 분석은 Oracle's Elixir 스타일 데이터 입력을 사용한다.
## Workflow
### Included lightweight local pipeline
이 k-skill 팩에는 경량 로컬 파일 기반 파이프라인 스크립트가 포함된다.
- `scripts/sync-oracle.js`: Oracle-style CSV → historical cache JSON
- `scripts/build-match-report.js`: 날짜별 match analysis 생성
- `scripts/analyze-live-game.js`: game analysis 생성
- 기본 cache 위치: `.openclaw-lck-cache/`
### 1. Basic scoreboard / standings query
```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, "lck-analytics", "src", "index.js"),
).href;
const { getLckSummary } = await import(entry);
const summary = await getLckSummary("2026-04-01", {
team: "한화",
includeStandings: true,
});
console.log(JSON.stringify(summary, null, 2));
JS
```
### 2. Historical analytics from Oracle-style CSV
직접 API를 호출해도 되지만, local skill pipeline에서는 아래 스크립트 사용을 우선 권장한다.
```bash
node ./lck-analytics/scripts/sync-oracle.js \
--csv ./lck-analytics/samples/oracle-lck-sample.csv
```
### 3. Match analysis via local pipeline script
```bash
node ./lck-analytics/scripts/build-match-report.js \
--date 2026-04-01
```
필요하면 팀 필터도 같이 준다.
```bash
node ./lck-analytics/scripts/build-match-report.js \
--date 2026-04-01 \
--team 한화
```
### 4. Game analysis with turning points via local pipeline script
```bash
node ./lck-analytics/scripts/analyze-live-game.js \
--game game-id
```
fixture 기반으로 분석할 때는 `--window`, `--details` 를 같이 줄 수 있다.
## Output guidelines
사용자에게는 원본 JSON을 길게 그대로 던지지 말고 먼저 아래 순서로 정리한다.
### 경기 결과 요청
- 경기 시각
- 팀1 vs 팀2
- 상태
- 세트 스코어
- 요청 팀 경기만 있으면 해당 경기 우선
- standings 요청이 있으면 현재 순위 같이 표시
### 진행 중 경기 요청
- 현재 게임 번호
- 킬 차이
- 골드 차이
- 드래곤/바론/타워 차이
- turning point 1~3개
### historical / meta 요청
- sample 수를 먼저 표시
- 팀 파워 레이팅은 상위 팀부터 정렬
- champion matchup / synergy는 표본 수가 적으면 낮은 확신도로 표시
- patch meta는 top picks / risers 위주로 짧게 요약
## Done when
- 날짜 기준 경기 요약이 있다
- 요청 팀 필터가 적용된다
- standings 요청이면 현재 순위가 같이 정리된다
- live 요청이면 현재 게임 요약과 turning point가 있다
- historical 입력이 있으면 patch meta 또는 power rating까지 설명할 수 있다
## Failure modes
- Riot 웹앱 API 구조/헤더가 바뀌면 패키지 수정이 필요할 수 있다
- `LOLESPORTS_API_KEY` public fallback이 회전되면 환경변수 override가 필요할 수 있다
- historical CSV 컬럼명이 너무 다르면 Oracle-style 정규화 전에 전처리가 필요할 수 있다
## Notes
- 이 스킬은 조회/분석 전용이다
- 사용자의 "오늘/어제" 요청은 항상 절대 날짜(`YYYY-MM-DD`)로 변환해서 실행한다
- 이 저장소에서 `main` 으로 머지되면 Changesets가 Version Packages PR을 만들고, 그 PR이 merge된 뒤 npm publish가 실행된다

View file

@ -0,0 +1,5 @@
league,matchid,date,patch,side,teamname,opponentteam,playername,position,champion,opponentchampion,result,gd15,csd15,xpd15,drg,bn,blindpick,counterpick
LCK,match-1,2026-04-01,16.6.753.8272,blue,Hanwha Life Esports,T1,HLE Zeus,top,Aatrox,Gnar,win,1200,18,340,100,100,0,1
LCK,match-1,2026-04-01,16.6.753.8272,blue,Hanwha Life Esports,T1,HLE Peanut,jungle,Vi,Sejuani,win,800,5,280,100,100,1,0
LCK,match-1,2026-04-01,16.6.753.8272,red,T1,Hanwha Life Esports,T1 Doran,top,Gnar,Aatrox,loss,-1200,-18,-340,0,0,1,0
LCK,match-1,2026-04-01,16.6.753.8272,red,T1,Hanwha Life Esports,T1 Oner,jungle,Sejuani,Vi,loss,-800,-5,-280,0,0,0,1
1 league matchid date patch side teamname opponentteam playername position champion opponentchampion result gd15 csd15 xpd15 drg bn blindpick counterpick
2 LCK match-1 2026-04-01 16.6.753.8272 blue Hanwha Life Esports T1 HLE Zeus top Aatrox Gnar win 1200 18 340 100 100 0 1
3 LCK match-1 2026-04-01 16.6.753.8272 blue Hanwha Life Esports T1 HLE Peanut jungle Vi Sejuani win 800 5 280 100 100 1 0
4 LCK match-1 2026-04-01 16.6.753.8272 red T1 Hanwha Life Esports T1 Doran top Gnar Aatrox loss -1200 -18 -340 0 0 1 0
5 LCK match-1 2026-04-01 16.6.753.8272 red T1 Hanwha Life Esports T1 Oner jungle Sejuani Vi loss -800 -5 -280 0 0 0 1

103
lck-analytics/scripts/_lib.js Executable file
View file

@ -0,0 +1,103 @@
const fs = require("node:fs");
const path = require("node:path");
const { pathToFileURL } = require("node:url");
async function loadLckResults() {
const candidates = [];
const packageNames = ["lck-analytics", "lck-results"];
if (process.env.GLOBAL_NPM_ROOT) {
for (const packageName of packageNames) {
candidates.push(path.join(process.env.GLOBAL_NPM_ROOT, packageName, "src", "index.js"));
}
}
try {
const globalRoot = await detectGlobalNpmRoot();
for (const packageName of packageNames) {
candidates.push(path.join(globalRoot, packageName, "src", "index.js"));
}
} catch {
// ignore detection failure and continue to local fallback
}
candidates.push(path.resolve(__dirname, "..", "..", "packages", "lck-analytics", "src", "index.js"));
const entryPath = candidates.find((candidate) => fs.existsSync(candidate));
if (!entryPath) {
throw new Error("Could not find lck-analytics package. Install it globally with `npm install -g lck-analytics` or run from the k-skill repo.");
}
return import(pathToFileURL(entryPath).href);
}
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function readJson(filePath, fallback = null) {
if (!fs.existsSync(filePath)) {
return fallback;
}
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function writeJson(filePath, value) {
ensureDir(path.dirname(filePath));
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function readText(filePath, fallback = "") {
if (!fs.existsSync(filePath)) {
return fallback;
}
return fs.readFileSync(filePath, "utf8");
}
function parseArgs(argv) {
const args = {};
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (!token.startsWith("--")) {
continue;
}
const key = token.slice(2);
const next = argv[index + 1];
if (!next || next.startsWith("--")) {
args[key] = true;
continue;
}
args[key] = next;
index += 1;
}
return args;
}
function formatOutput(value) {
return `${JSON.stringify(value, null, 2)}\n`;
}
function resolveCachePaths(baseDir) {
return {
root: baseDir,
historical: path.join(baseDir, "historical-analysis.json"),
live: path.join(baseDir, "live"),
reports: path.join(baseDir, "reports"),
};
}
async function detectGlobalNpmRoot() {
const { execFileSync } = require("node:child_process");
return execFileSync("npm", ["root", "-g"], { encoding: "utf8" }).trim();
}
module.exports = {
ensureDir,
formatOutput,
loadLckResults,
parseArgs,
readJson,
readText,
resolveCachePaths,
writeJson,
};

View file

@ -0,0 +1,52 @@
#!/usr/bin/env node
const fs = require("node:fs");
const path = require("node:path");
const {
formatOutput,
loadLckResults,
parseArgs,
readJson,
resolveCachePaths,
writeJson,
} = require("./_lib");
async function main() {
const args = parseArgs(process.argv.slice(2));
const gameId = args.game;
if (!gameId) {
throw new Error("--game <gameId> is required");
}
const cacheDir = path.resolve(args.cache || path.join(process.cwd(), ".openclaw-lck-cache"));
const paths = resolveCachePaths(cacheDir);
const historicalWrapper = readJson(paths.historical, { data: {} });
const pkg = await loadLckResults();
const liveWindowPayload = args.window ? JSON.parse(fs.readFileSync(path.resolve(args.window), "utf8")) : undefined;
const liveDetailsPayload = args.details ? JSON.parse(fs.readFileSync(path.resolve(args.details), "utf8")) : undefined;
const analysis = await pkg.getGameAnalysis(gameId, {
matchId: args.match,
number: args.number ? Number(args.number) : null,
state: args.state || undefined,
historicalDataset: historicalWrapper.data,
liveWindowPayload,
liveDetailsPayload,
});
const reportFile = path.join(paths.reports, `game-${gameId}.json`);
writeJson(reportFile, analysis);
process.stdout.write(formatOutput({
ok: true,
reportFile,
patch: analysis.patch,
turningPoints: analysis.turningPoints,
draftEdge: analysis.draft?.overallEdge || null,
}));
}
main().catch((error) => {
console.error(error.stack || String(error));
process.exitCode = 1;
});

View file

@ -0,0 +1,44 @@
#!/usr/bin/env node
const path = require("node:path");
const {
formatOutput,
loadLckResults,
parseArgs,
readJson,
resolveCachePaths,
writeJson,
} = require("./_lib");
async function main() {
const args = parseArgs(process.argv.slice(2));
const date = args.date;
if (!date) {
throw new Error("--date <YYYY-MM-DD> is required");
}
const cacheDir = path.resolve(args.cache || path.join(process.cwd(), ".openclaw-lck-cache"));
const paths = resolveCachePaths(cacheDir);
const historicalWrapper = readJson(paths.historical, { data: {} });
const pkg = await loadLckResults();
const analysis = await pkg.getMatchAnalysis(date, {
team: args.team || undefined,
historicalDataset: historicalWrapper.data,
});
const reportFile = path.join(paths.reports, `match-${date}${args.team ? `-${args.team}` : ""}.json`);
writeJson(reportFile, analysis);
process.stdout.write(formatOutput({
ok: true,
reportFile,
queryDate: analysis.queryDate,
matchCount: analysis.matches.length,
teams: analysis.matches.map((match) => `${match.team1?.name} vs ${match.team2?.name}`),
}));
}
main().catch((error) => {
console.error(error.stack || String(error));
process.exitCode = 1;
});

View file

@ -0,0 +1,50 @@
#!/usr/bin/env node
const path = require("node:path");
const {
formatOutput,
loadLckResults,
parseArgs,
readText,
resolveCachePaths,
writeJson,
} = require("./_lib");
async function main() {
const args = parseArgs(process.argv.slice(2));
const cacheDir = path.resolve(args.cache || path.join(process.cwd(), ".openclaw-lck-cache"));
const csvPath = args.csv ? path.resolve(args.csv) : path.join(__dirname, "..", "samples", "oracle-lck-sample.csv");
const league = args.league || "LCK";
const csvText = readText(csvPath);
if (!csvText.trim()) {
throw new Error(`CSV not found or empty: ${csvPath}`);
}
const pkg = await loadLckResults();
const historical = pkg.buildHistoricalAnalytics(csvText, { league });
const paths = resolveCachePaths(cacheDir);
writeJson(paths.historical, {
source: {
type: "oracle-style-csv",
csvPath,
league,
updatedAt: new Date().toISOString(),
},
data: historical,
});
process.stdout.write(formatOutput({
ok: true,
cacheFile: paths.historical,
teamRatings: historical.teamPowerRatings.length,
matchupStats: historical.matchupStats.length,
synergyStats: historical.synergyStats.length,
patchMeta: historical.patchMeta.length,
}));
}
main().catch((error) => {
console.error(error.stack || String(error));
process.exitCode = 1;
});

16
package-lock.json generated
View file

@ -585,10 +585,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/coupang-product-search": {
"resolved": "packages/coupang-product-search",
"link": true
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"dev": true,
@ -993,6 +989,10 @@
"resolved": "packages/kleague-results",
"link": true
},
"node_modules/lck-analytics": {
"resolved": "packages/lck-analytics",
"link": true
},
"node_modules/light-my-request": {
"version": "6.6.0",
"funding": [
@ -1624,6 +1624,7 @@
},
"packages/coupang-product-search": {
"version": "0.1.0",
"extraneous": true,
"license": "MIT",
"engines": {
"node": ">=18"
@ -1667,6 +1668,13 @@
"node": ">=18"
}
},
"packages/lck-analytics": {
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/toss-securities": {
"version": "0.1.0",
"license": "MIT",

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 && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace toss-securities --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 blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-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 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",
"release:npm": "changeset publish"

View file

@ -0,0 +1,151 @@
# lck-analytics
`jerjangmin`님의 원본 [`lck-analytics` 스킬 팩](https://github.com/jerjangmin/share/tree/main/SKILL/lck-analytics)을 k-skill의 npm workspace / Changesets 배포 흐름에 맞춰 옮긴 LCK 전용 Node.js 클라이언트입니다.
Riot 공식 LoL Esports 데이터와 Oracle's Elixir 스타일 historical row / CSV를 함께 사용해 날짜별 LCK 경기 결과, 현재 순위, live turning point, 밴픽 matchup / synergy, patch meta, 팀 파워 레이팅을 계산합니다.
## Install
```bash
npm install lck-analytics
```
글로벌 skill 실행 예시는 아래를 기준으로 합니다.
```bash
npm install -g lck-analytics
```
## Origin / attribution
- Original skill + prototype package: <https://github.com/jerjangmin/share/tree/main/SKILL/lck-analytics>
- Original author: `jerjangmin`
- k-skill adaptation goal: same capability surface, but released through this repository's official npm/Changesets pipeline
## Official surfaces
- 일정/결과: `https://esports-api.lolesports.com/persisted/gw/getSchedule`
- 토너먼트 목록: `https://esports-api.lolesports.com/persisted/gw/getTournamentsForLeague`
- 순위: `https://esports-api.lolesports.com/persisted/gw/getStandings`
- 이벤트 상세: `https://esports-api.lolesports.com/persisted/gw/getEventDetails`
- 라이브 window: `https://feed.lolesports.com/livestats/v1/window/{gameId}`
- 라이브 details: `https://feed.lolesports.com/livestats/v1/details/{gameId}`
## Usage
```js
const {
buildHistoricalAnalytics,
getGameAnalysis,
getLckSummary,
getMatchAnalysis,
getMatchResults,
getPatchMetaReport,
getStandings,
getTeamPowerRatings,
} = require("lck-analytics");
(async () => {
const results = await getMatchResults("2026-04-01", {
team: "한화",
});
const standings = await getStandings({
date: "2026-04-01",
team: "T1",
});
const summary = await getLckSummary("2026-04-01", {
team: "한화",
includeStandings: true,
});
const historical = buildHistoricalAnalytics([
{
league: "LCK",
matchid: "sample-1",
date: "2026-04-01",
patch: "16.6.753.8272",
side: "blue",
teamname: "Hanwha Life Esports",
opponentteam: "T1",
playername: "HLE Zeus",
position: "top",
champion: "Aatrox",
opponentchampion: "Gnar",
result: "win",
gd15: 1200,
csd15: 18,
xpd15: 340,
drg: 100,
bn: 100,
blindpick: 0,
counterpick: 1,
},
]);
const patchMeta = getPatchMetaReport(historical, "16.6.753.8272");
const ratings = getTeamPowerRatings(historical);
const gameAnalysis = await getGameAnalysis("game-id", {
historicalDataset: historical,
liveWindowPayload: {/* optional cached payload */},
liveDetailsPayload: {/* optional cached payload */},
});
const matchAnalysis = await getMatchAnalysis("2026-04-01", {
historicalDataset: historical,
});
console.log(results.matches[0]);
console.log(standings.rows[0]);
console.log(summary);
console.log(patchMeta);
console.log(ratings[0]);
console.log(gameAnalysis.turningPoints);
console.log(matchAnalysis.matches[0]?.powerPreview);
})();
```
## API
### `getMatchResults(date, options)`
- `date`: `YYYY-MM-DD` 또는 `Date`
- `options.team`: 현재명 / 과거명 / 한글 / 영문 / 약칭 alias
- `options.maxPages`: 일정 페이지 탐색 상한, 기본값 `6`
- 기본적으로 `matches[*].games[*].live``matches[*].live` 에 인게임 실시간 요약을 채웁니다
- `options.includeLiveDetails === false` 이면 상세 live fetch를 생략합니다
### `getStandings(options)`
- `options.date`: `YYYY-MM-DD` 또는 `Date`
- `options.tournamentId`: 특정 토너먼트 강제 지정 가능
- `options.team`: 특정 팀만 현재 순위에서 필터링
### `getLckSummary(date, options)`
- 날짜 결과와 해당 시점 스플릿 순위를 한 번에 반환합니다
### `buildHistoricalAnalytics(input, options)`
- Oracle's Elixir 스타일 CSV 문자열 또는 row 배열로 historical 분석 데이터셋을 만듭니다
- 반환값에는 `teamPowerRatings`, `championStats`, `matchupStats`, `synergyStats`, `patchMeta` 가 포함됩니다
### `getGameAnalysis(gameId, options)`
- live window/details payload를 기반으로 timeline, turning points, draft edge, patch meta context를 계산합니다
### `getMatchAnalysis(date, options)`
- 날짜별 match 결과 위에 게임별 분석과 팀 파워 preview를 붙여 반환합니다
## Release note
이 패키지는 `packages/lck-analytics` workspace로 관리됩니다. `main` 에 머지되면 이 저장소의 Changesets 흐름이 **Version Packages PR** 을 만들고, 그 PR merge 후 npm publish가 실행됩니다.
## Notes
- 기본 Riot web header + 공개 API key fallback을 사용하지만, 안정성을 위해 `LOLESPORTS_API_KEY` 환경변수 override도 지원합니다
- turning point 분석은 공개 live snapshot 기반 heuristic 입니다
- `DN SOOPers`, `DN FREECS`, `광동 프릭스`, `Afreeca Freecs` 같은 리브랜딩 alias를 같은 canonical team으로 정규화합니다

View file

@ -0,0 +1,33 @@
{
"name": "lck-analytics",
"version": "0.1.0",
"description": "LCK match analytics and insights powered by Riot LoL Esports data",
"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",
"lck",
"league-of-legends",
"esports"
],
"scripts": {
"lint": "node --check src/index.js && node --check src/parse.js && node --check src/teams.js && node --check src/oracle.js && node --check src/analytics.js && node --check test/index.test.js",
"test": "node --test"
},
"author": "k-skill maintainers (adapted from jerjangmin's original lck-analytics pack)"
}

View file

@ -0,0 +1,332 @@
const { normalizeLiveGameResponse } = require("./parse");
function normalizeLiveTimeline(windowPayload, detailsPayload, options = {}) {
const windowFrames = Array.isArray(windowPayload?.frames) ? windowPayload.frames : [];
const detailsFrames = Array.isArray(detailsPayload?.frames) ? detailsPayload.frames : [];
const detailsByTimestamp = new Map(detailsFrames.map((frame) => [frame.rfc460Timestamp, frame]));
const firstTimestamp = windowFrames[0]?.rfc460Timestamp || detailsFrames[0]?.rfc460Timestamp || null;
return windowFrames
.map((windowFrame) => normalizeTimelineFrame(windowPayload, windowFrame, detailsByTimestamp.get(windowFrame.rfc460Timestamp), {
...options,
firstTimestamp,
}))
.filter(Boolean);
}
function normalizeTimelineFrame(windowPayload, windowFrame, detailsFrame, options = {}) {
const live = normalizeLiveGameResponse({
esportsGameId: windowPayload?.esportsGameId,
esportsMatchId: windowPayload?.esportsMatchId,
gameMetadata: windowPayload?.gameMetadata,
frames: [windowFrame],
}, {
frames: detailsFrame ? [detailsFrame] : [],
}, options);
if (!live) {
return null;
}
return {
timestamp: windowFrame.rfc460Timestamp,
gameState: windowFrame.gameState || null,
durationSeconds: options.firstTimestamp
? Math.max(0, Math.round((new Date(windowFrame.rfc460Timestamp) - new Date(options.firstTimestamp)) / 1000))
: live.durationSeconds,
blueTeam: live.blueTeam,
redTeam: live.redTeam,
goldDiff: live.goldDiff,
killDiff: live.killDiff,
objectiveScore: computeObjectiveScore(live.blueTeam) - computeObjectiveScore(live.redTeam),
};
}
function analyzeTurningPoints(timeline, options = {}) {
const thresholdGoldSwing = options.thresholdGoldSwing || 1800;
const thresholdObjectiveSwing = options.thresholdObjectiveSwing || 2;
const candidates = [];
for (let index = 1; index < timeline.length; index += 1) {
const previous = timeline[index - 1];
const current = timeline[index];
const goldSwing = Math.abs((current.goldDiff ?? 0) - (previous.goldDiff ?? 0));
const killSwing = Math.abs((current.killDiff ?? 0) - (previous.killDiff ?? 0));
const objectiveSwing = Math.abs((current.objectiveScore ?? 0) - (previous.objectiveScore ?? 0));
const leadFlip = Math.sign(current.goldDiff || 0) !== 0
&& Math.sign(previous.goldDiff || 0) !== 0
&& Math.sign(current.goldDiff || 0) !== Math.sign(previous.goldDiff || 0);
if (!leadFlip && goldSwing < thresholdGoldSwing && objectiveSwing < thresholdObjectiveSwing && killSwing < 3) {
continue;
}
const favoredSide = resolveFavoredSide(current.goldDiff);
candidates.push({
timestamp: current.timestamp,
durationSeconds: current.durationSeconds,
goldSwing,
killSwing,
objectiveSwing,
favoredSide,
swingScore: round((goldSwing / 300) + (killSwing * 3) + (objectiveSwing * 6) + (leadFlip ? 10 : 0), 2),
reason: summarizeTurningPoint(previous, current, { goldSwing, killSwing, objectiveSwing, leadFlip, favoredSide }),
});
}
return candidates.sort((left, right) => right.swingScore - left.swingScore).slice(0, options.limit || 3);
}
function summarizeTurningPoint(previous, current, context) {
const pieces = [];
if (context.leadFlip) {
pieces.push("골드 리드가 뒤집혔습니다");
}
if (context.goldSwing >= 1800) {
pieces.push(`골드 차이가 ${formatSigned(current.goldDiff)}로 크게 움직였습니다`);
}
if (context.killSwing >= 3) {
pieces.push(`교전으로 킬 차이가 ${formatSigned(current.killDiff)}가 됐습니다`);
}
if (context.objectiveSwing >= 2) {
pieces.push("주요 오브젝트 격차가 벌어졌습니다");
}
if (pieces.length === 0) {
pieces.push("중요한 흐름 변화가 감지됐습니다");
}
const side = context.favoredSide === "blue" ? "블루" : context.favoredSide === "red" ? "레드" : null;
return side ? `${side} 진영 기준 turning point: ${pieces.join(", ")}` : pieces.join(", ");
}
function analyzeDraft(game, historicalDataset, options = {}) {
const patch = options.patch || game?.live?.patchVersion || null;
const blueParticipants = game?.live?.blueTeam?.participants || [];
const redParticipants = game?.live?.redTeam?.participants || [];
const matchupStats = historicalDataset?.matchupStats || [];
const synergyStats = historicalDataset?.synergyStats || [];
const roleMatchups = blueParticipants.map((blueParticipant) => {
const redParticipant = redParticipants.find((candidate) => candidate.role === blueParticipant.role);
if (!redParticipant) {
return null;
}
const blueEdge = findMatchup(matchupStats, patch, blueParticipant.role, blueParticipant.championId, redParticipant.championId);
const redEdge = findMatchup(matchupStats, patch, redParticipant.role, redParticipant.championId, blueParticipant.championId);
const favoredSide = pickFavoredSide(blueEdge, redEdge);
return {
role: blueParticipant.role,
blueChampion: blueParticipant.championId,
redChampion: redParticipant.championId,
favoredSide,
blueSample: blueEdge?.games || 0,
redSample: redEdge?.games || 0,
summary: summarizeMatchup(blueEdge, redEdge, blueParticipant, redParticipant),
};
}).filter(Boolean);
const blueSynergy = scoreSynergy(blueParticipants, synergyStats, patch);
const redSynergy = scoreSynergy(redParticipants, synergyStats, patch);
return {
patch,
roleMatchups,
blueSynergy,
redSynergy,
overallEdge: pickOverallDraftEdge(roleMatchups, blueSynergy, redSynergy),
};
}
function scoreSynergy(participants, synergyStats, patch) {
const champions = participants.map((participant) => participant.championId).filter(Boolean);
const pairs = [];
for (let index = 0; index < champions.length; index += 1) {
for (let inner = index + 1; inner < champions.length; inner += 1) {
const championA = champions[index];
const championB = champions[inner];
const stat = synergyStats.find((entry) => (
(!patch || entry.patch === patch)
&& ((entry.championA === championA && entry.championB === championB)
|| (entry.championA === championB && entry.championB === championA))
));
if (stat) {
pairs.push(stat);
}
}
}
return {
samplePairs: pairs.length,
avgWinRate: round(average(pairs.map((entry) => entry.winRate)), 2),
topPairs: pairs.sort((left, right) => right.games - left.games).slice(0, 3),
};
}
function summarizeMetaForGame(game, historicalDataset, options = {}) {
const patch = options.patch || game?.live?.patchVersion || null;
const patchMeta = (historicalDataset?.patchMeta || []).find((entry) => !patch || entry.patch === patch) || null;
return {
patch,
topPicks: patchMeta?.topPicks || [],
risers: patchMeta?.risers || [],
};
}
function buildGameAnalysis(game, historicalDataset, options = {}) {
const timeline = normalizeLiveTimeline(options.liveWindowPayload, options.liveDetailsPayload, {
gameId: game.id,
matchId: options.matchId,
});
const currentLive = timeline.at(-1) ? {
gameId: game.id,
patchVersion: options.liveWindowPayload?.gameMetadata?.patchVersion || null,
durationSeconds: timeline.at(-1).durationSeconds,
blueTeam: timeline.at(-1).blueTeam,
redTeam: timeline.at(-1).redTeam,
goldDiff: timeline.at(-1).goldDiff,
killDiff: timeline.at(-1).killDiff,
} : game.live || null;
const enrichedGame = {
...game,
live: currentLive,
};
return {
gameId: game.id,
number: game.number,
state: game.state,
patch: currentLive?.patchVersion || null,
current: currentLive,
timeline,
turningPoints: analyzeTurningPoints(timeline, options.turningPointOptions),
draft: analyzeDraft(enrichedGame, historicalDataset, {
patch: currentLive?.patchVersion || null,
}),
meta: summarizeMetaForGame(enrichedGame, historicalDataset, {
patch: currentLive?.patchVersion || null,
}),
};
}
function buildTeamPreview(teamId, historicalDataset) {
const ratings = historicalDataset?.teamPowerRatings || [];
return ratings.find((entry) => entry.teamId === teamId) || null;
}
function compareTeams(teamAId, teamBId, historicalDataset) {
const left = buildTeamPreview(teamAId, historicalDataset);
const right = buildTeamPreview(teamBId, historicalDataset);
return {
teamA: left,
teamB: right,
favoredTeamId: !left || !right ? left?.teamId || right?.teamId || null : left.powerScore >= right.powerScore ? left.teamId : right.teamId,
powerGap: left && right ? round(Math.abs(left.powerScore - right.powerScore), 2) : null,
};
}
function getPatchMetaSummary(historicalDataset, patch) {
return (historicalDataset?.patchMeta || []).find((entry) => entry.patch === patch) || null;
}
function findMatchup(matchupStats, patch, role, champion, opponentChampion) {
return matchupStats.find((entry) => (
(!patch || entry.patch === patch)
&& entry.position === role
&& entry.champion === champion
&& entry.opponentChampion === opponentChampion
)) || null;
}
function pickFavoredSide(blueEdge, redEdge) {
const blueScore = scoreMatchup(blueEdge);
const redScore = scoreMatchup(redEdge);
if (blueScore === redScore) {
return null;
}
return blueScore > redScore ? "blue" : "red";
}
function scoreMatchup(edge) {
if (!edge) {
return 0;
}
return (edge.winRate || 0) + Math.min(edge.games || 0, 10);
}
function summarizeMatchup(blueEdge, redEdge, blueParticipant, redParticipant) {
if (!blueEdge && !redEdge) {
return `${blueParticipant.championId} vs ${redParticipant.championId} 매치업 표본이 부족합니다.`;
}
const favored = pickFavoredSide(blueEdge, redEdge);
if (favored === "blue") {
return `${blueParticipant.championId} 쪽이 유리한 매치업으로 보입니다.`;
}
if (favored === "red") {
return `${redParticipant.championId} 쪽이 유리한 매치업으로 보입니다.`;
}
return `${blueParticipant.championId} vs ${redParticipant.championId} 매치업은 팽팽합니다.`;
}
function pickOverallDraftEdge(roleMatchups, blueSynergy, redSynergy) {
const blueWins = roleMatchups.filter((entry) => entry.favoredSide === "blue").length;
const redWins = roleMatchups.filter((entry) => entry.favoredSide === "red").length;
const blueScore = blueWins + ((blueSynergy.avgWinRate || 50) / 100);
const redScore = redWins + ((redSynergy.avgWinRate || 50) / 100);
if (blueScore === redScore) {
return null;
}
return blueScore > redScore ? "blue" : "red";
}
function computeObjectiveScore(team) {
if (!team) {
return 0;
}
return (team.towers || 0)
+ ((team.inhibitors || 0) * 1.5)
+ ((team.barons || 0) * 3)
+ ((team.dragonCount || 0) * 1.2);
}
function resolveFavoredSide(goldDiff) {
if (!Number.isFinite(goldDiff) || goldDiff === 0) {
return null;
}
return goldDiff > 0 ? "blue" : "red";
}
function formatSigned(value) {
if (!Number.isFinite(value)) {
return "0";
}
return `${value > 0 ? "+" : ""}${Math.round(value)}`;
}
function average(values) {
const filtered = values.filter((value) => Number.isFinite(value));
if (filtered.length === 0) {
return null;
}
return filtered.reduce((sum, value) => sum + value, 0) / filtered.length;
}
function round(value, digits = 2) {
if (!Number.isFinite(value)) {
return null;
}
const scale = 10 ** digits;
return Math.round(value * scale) / scale;
}
module.exports = {
analyzeDraft,
analyzeTurningPoints,
buildGameAnalysis,
buildTeamPreview,
compareTeams,
getPatchMetaSummary,
normalizeLiveTimeline,
};

View file

@ -0,0 +1,24 @@
const LCK_LEAGUE_ID = "98767991310872058";
const DEFAULT_LOLESPORTS_API_KEY = "0TvQnueqKa5mxJntVWt0w4LpLfEkrV1Ta8rQBb9Z";
const LOLESPORTS_API_BASE_URL = "https://esports-api.lolesports.com/persisted/gw";
const LIVE_STATS_BASE_URL = "https://feed.lolesports.com/livestats/v1";
const DEFAULT_HEADERS = {
accept: "application/json",
"accept-language": "en-US,en;q=0.9,ko-KR;q=0.8,ko;q=0.7",
"user-agent": "k-skill/lck-analytics",
};
const STATUS_MAP = {
completed: { state: "finished", label: "종료", finished: true },
inProgress: { state: "live", label: "진행 중", finished: false },
unstarted: { state: "scheduled", label: "예정", finished: false },
};
module.exports = {
DEFAULT_HEADERS,
DEFAULT_LOLESPORTS_API_KEY,
LCK_LEAGUE_ID,
LIVE_STATS_BASE_URL,
LOLESPORTS_API_BASE_URL,
STATUS_MAP,
};

View file

@ -0,0 +1,461 @@
const {
DEFAULT_HEADERS,
DEFAULT_LOLESPORTS_API_KEY,
LCK_LEAGUE_ID,
LIVE_STATS_BASE_URL,
LOLESPORTS_API_BASE_URL,
} = require("./constants");
const {
normalizeDateInput,
normalizeEventDetailsResponse,
normalizeLiveGameResponse,
normalizeScheduleResponse,
normalizeStandingsResponse,
normalizeTournamentList,
resolveTournamentForDate,
} = require("./parse");
const {
buildGameAnalysis,
compareTeams,
getPatchMetaSummary,
} = require("./analytics");
const {
buildHistoricalDataset,
parseOracleCsv,
} = require("./oracle");
async function requestJson(path, options = {}) {
const fetchImpl = options.fetchImpl || global.fetch;
if (typeof fetchImpl !== "function") {
throw new Error("A fetch implementation is required.");
}
const url = new URL(`${LOLESPORTS_API_BASE_URL}/${path}`);
for (const [key, value] of Object.entries(options.query || {})) {
if (value !== null && value !== undefined && value !== "") {
url.searchParams.set(key, String(value));
}
}
const response = await fetchImpl(url, {
method: "GET",
headers: {
...DEFAULT_HEADERS,
"x-api-key": options.apiKey || process.env.LOLESPORTS_API_KEY || DEFAULT_LOLESPORTS_API_KEY,
...(options.headers || {}),
},
signal: options.signal,
});
if (!response.ok) {
throw new Error(`LoL Esports request failed with ${response.status} for ${url}`);
}
return response.json();
}
async function fetchSchedulePage(options = {}) {
return requestJson("getSchedule", {
query: {
hl: options.hl || "en-US",
leagueId: options.leagueId || LCK_LEAGUE_ID,
pageToken: options.pageToken,
},
fetchImpl: options.fetchImpl,
apiKey: options.apiKey,
signal: options.signal,
});
}
async function fetchTournaments(options = {}) {
return requestJson("getTournamentsForLeague", {
query: {
hl: options.hl || "en-US",
leagueId: options.leagueId || LCK_LEAGUE_ID,
},
fetchImpl: options.fetchImpl,
apiKey: options.apiKey,
signal: options.signal,
});
}
async function fetchStandings(options = {}) {
return requestJson("getStandings", {
query: {
hl: options.hl || "en-US",
tournamentId: options.tournamentId,
},
fetchImpl: options.fetchImpl,
apiKey: options.apiKey,
signal: options.signal,
});
}
async function fetchEventDetails(options = {}) {
return requestJson("getEventDetails", {
query: {
hl: options.hl || "en-US",
id: options.eventId,
},
fetchImpl: options.fetchImpl,
apiKey: options.apiKey,
signal: options.signal,
});
}
async function requestLiveJson(kind, gameId, options = {}) {
const fetchImpl = options.fetchImpl || global.fetch;
if (typeof fetchImpl !== "function") {
throw new Error("A fetch implementation is required.");
}
const url = new URL(`${LIVE_STATS_BASE_URL}/${kind}/${gameId}`);
const response = await fetchImpl(url, {
method: "GET",
headers: {
accept: "application/json",
"user-agent": "k-skill/lck-analytics",
...(options.headers || {}),
},
signal: options.signal,
});
if (response.status === 204) {
return null;
}
if (!response.ok) {
throw new Error(`LoL Esports live stats request failed with ${response.status} for ${url}`);
}
return response.json();
}
async function fetchLiveWindow(options = {}) {
return requestLiveJson("window", options.gameId, options);
}
async function fetchLiveDetails(options = {}) {
return requestLiveJson("details", options.gameId, options);
}
async function getMatchResults(date, options = {}) {
const requestedDate = normalizeDateInput(date).isoDate;
const pages = [];
let payload = options.schedulePayload || await fetchSchedulePage(options);
pages.push(payload);
const maxPages = Number(options.maxPages || 6);
let direction = resolveSearchDirection(payload, requestedDate);
let pageCount = 1;
while (direction && pageCount < maxPages) {
const pageToken = payload?.data?.schedule?.pages?.[direction];
if (!pageToken) {
break;
}
payload = await fetchSchedulePage({
...options,
pageToken,
});
pages.push(payload);
pageCount += 1;
direction = resolveSearchDirection(payload, requestedDate);
}
const mergedPayload = mergeSchedulePages(pages);
const result = normalizeScheduleResponse(mergedPayload, {
date: requestedDate,
team: options.team,
});
const matches = options.includeLiveDetails === false
? result.matches
: await enrichMatchesWithLiveDetails(result.matches, options);
return {
...result,
matches,
pagesExamined: pages.length,
};
}
async function getStandings(options = {}) {
const tournamentsPayload = options.tournamentsPayload || await fetchTournaments(options);
const tournaments = normalizeTournamentList(tournamentsPayload);
const tournament = options.tournamentId
? tournaments.find((candidate) => candidate.id === String(options.tournamentId)) || { id: String(options.tournamentId) }
: resolveTournamentForDate(tournaments, options.date || new Date());
if (!tournament?.id) {
return {
tournamentId: null,
tournamentName: null,
stage: null,
sectionName: null,
filteredTeam: options.team ? { input: options.team, canonicalId: null, normalized: options.team } : null,
rows: [],
};
}
const standingsPayload = options.standingsPayload || await fetchStandings({
...options,
tournamentId: tournament.id,
});
return normalizeStandingsResponse(standingsPayload, {
tournament,
team: options.team,
});
}
async function enrichMatchesWithLiveDetails(matches, options = {}) {
return Promise.all(matches.map(async (match) => {
const eventDetailsPayload = options.eventDetailsByMatchId?.[match.eventId]
|| options.eventDetailsByMatchId?.[match.matchId]
|| await fetchEventDetails({
...options,
eventId: match.eventId || match.matchId,
});
const eventDetails = normalizeEventDetailsResponse(eventDetailsPayload);
const games = await Promise.all(eventDetails.games.map(async (game) => {
if (game.state !== "inProgress") {
return {
...game,
live: null,
};
}
const liveWindowPayload = options.liveWindowByGameId?.[game.id] !== undefined
? options.liveWindowByGameId[game.id]
: await fetchLiveWindow({ ...options, gameId: game.id });
const liveDetailsPayload = options.liveDetailsByGameId?.[game.id] !== undefined
? options.liveDetailsByGameId[game.id]
: await fetchLiveDetails({ ...options, gameId: game.id });
return {
...game,
live: normalizeLiveGameResponse(liveWindowPayload, liveDetailsPayload, {
gameId: game.id,
matchId: match.matchId,
}),
};
}));
return {
...match,
streams: eventDetails.streams,
games,
live: match.status.state === "live" ? summarizeMatchLive(games) : null,
};
}));
}
async function getLckSummary(date, options = {}) {
const matches = options.matchesResponse || await getMatchResults(date, options);
const summary = {
queryDate: matches.queryDate,
filteredTeam: matches.filteredTeam,
matches: matches.matches,
};
if (options.includeStandings !== false) {
summary.standings = await getStandings({
...options,
date,
team: options.team,
tournamentsPayload: options.tournamentsPayload,
standingsPayload: options.standingsPayload,
});
}
return summary;
}
function buildHistoricalAnalytics(input, options = {}) {
const rows = typeof input === "string" ? parseOracleCsv(input) : input;
return buildHistoricalDataset(Array.isArray(rows) ? rows : [], options);
}
async function getGameAnalysis(gameId, options = {}) {
const historical = options.historicalDataset
|| buildHistoricalAnalytics(options.oracleCsv || options.historicalRows || [], options);
const liveWindowPayload = options.liveWindowPayload !== undefined
? options.liveWindowPayload
: await fetchLiveWindow({ ...options, gameId });
const liveDetailsPayload = options.liveDetailsPayload !== undefined
? options.liveDetailsPayload
: await fetchLiveDetails({ ...options, gameId });
const game = options.game || {
id: gameId,
number: options.number || null,
state: options.state || (liveWindowPayload?.frames?.length ? "inProgress" : null),
live: null,
};
return buildGameAnalysis(game, historical, {
matchId: options.matchId,
liveWindowPayload,
liveDetailsPayload,
turningPointOptions: options.turningPointOptions,
});
}
async function getMatchAnalysis(date, options = {}) {
const matchesResponse = options.matchesResponse || await getMatchResults(date, options);
const historical = options.historicalDataset
|| buildHistoricalAnalytics(options.oracleCsv || options.historicalRows || [], options);
const matches = await Promise.all(matchesResponse.matches.map(async (match) => {
const baseMatch = options.includeLiveDetails === false || match.games ? match : (await enrichMatchesWithLiveDetails([match], options))[0];
const analyses = await Promise.all((baseMatch.games || []).map(async (game) => {
if (!game.live && options.liveWindowByGameId?.[game.id] === undefined && game.state !== "inProgress") {
return {
gameId: game.id,
number: game.number,
state: game.state,
patch: null,
current: null,
timeline: [],
turningPoints: [],
draft: null,
meta: null,
};
}
return getGameAnalysis(game.id, {
...options,
game,
matchId: baseMatch.matchId,
historicalDataset: historical,
liveWindowPayload: options.liveWindowByGameId?.[game.id],
liveDetailsPayload: options.liveDetailsByGameId?.[game.id],
});
}));
return {
...baseMatch,
analyses,
powerPreview: compareTeams(baseMatch.team1?.canonicalId, baseMatch.team2?.canonicalId, historical),
};
}));
return {
queryDate: matchesResponse.queryDate,
filteredTeam: matchesResponse.filteredTeam,
matches,
};
}
function getTeamPowerRatings(input, options = {}) {
const historical = input?.teamPowerRatings ? input : buildHistoricalAnalytics(input, options);
return historical.teamPowerRatings;
}
function getPatchMetaReport(input, patch, options = {}) {
const historical = input?.patchMeta ? input : buildHistoricalAnalytics(input, options);
return getPatchMetaSummary(historical, patch);
}
function mergeSchedulePages(pages) {
const merged = [];
const seen = new Set();
for (const payload of pages) {
for (const event of payload?.data?.schedule?.events || []) {
const key = event?.match?.id || event?.id;
if (!key || seen.has(key)) {
continue;
}
seen.add(key);
merged.push(event);
}
}
return {
data: {
schedule: {
events: merged,
pages: pages.at(-1)?.data?.schedule?.pages || { older: null, newer: null },
},
},
};
}
function summarizeMatchLive(games) {
const currentGame = games.find((game) => game.state === "inProgress" && game.live)
|| games.find((game) => game.live);
if (!currentGame) {
return null;
}
return {
currentGameNumber: currentGame.number,
currentGameState: currentGame.state,
gameId: currentGame.id,
gameState: currentGame.live?.gameState || null,
updatedAt: currentGame.live?.updatedAt || null,
durationSeconds: currentGame.live?.durationSeconds ?? null,
blueTeam: currentGame.live?.blueTeam || null,
redTeam: currentGame.live?.redTeam || null,
goldDiff: currentGame.live?.goldDiff ?? null,
killDiff: currentGame.live?.killDiff ?? null,
};
}
function resolveSearchDirection(payload, requestedDate) {
const eventDates = (payload?.data?.schedule?.events || [])
.map((event) => event?.startTime)
.filter(Boolean)
.map((startTime) => new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Seoul",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date(startTime)));
if (eventDates.length === 0) {
return null;
}
const sorted = [...eventDates].sort();
const minDate = sorted[0];
const maxDate = sorted.at(-1);
if (requestedDate < minDate) {
return "older";
}
if (requestedDate > maxDate) {
return "newer";
}
return null;
}
module.exports = {
buildHistoricalAnalytics,
enrichMatchesWithLiveDetails,
fetchEventDetails,
fetchLiveDetails,
fetchLiveWindow,
fetchSchedulePage,
fetchStandings,
fetchTournaments,
getGameAnalysis,
getLckSummary,
getMatchAnalysis,
getMatchResults,
getPatchMetaReport,
getStandings,
getTeamPowerRatings,
mergeSchedulePages,
parseOracleCsv,
requestJson,
requestLiveJson,
resolveSearchDirection,
summarizeMatchLive,
};

View file

@ -0,0 +1,425 @@
const { resolveTeamQuery } = require("./teams");
function parseOracleCsv(csvText) {
const text = String(csvText || "").trim();
if (!text) {
return [];
}
const rows = parseCsv(text);
if (rows.length === 0) {
return [];
}
const [headerRow, ...bodyRows] = rows;
const headers = headerRow.map((value) => String(value || "").trim());
return bodyRows
.filter((row) => row.some((cell) => String(cell || "").trim() !== ""))
.map((row) => Object.fromEntries(headers.map((header, index) => [header, normalizeCell(row[index])] )));
}
function parseCsv(text) {
const rows = [];
let row = [];
let cell = "";
let inQuotes = false;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
const next = text[index + 1];
if (char === '"') {
if (inQuotes && next === '"') {
cell += '"';
index += 1;
} else {
inQuotes = !inQuotes;
}
continue;
}
if (!inQuotes && char === ',') {
row.push(cell);
cell = "";
continue;
}
if (!inQuotes && (char === '\n' || char === '\r')) {
if (char === '\r' && next === '\n') {
index += 1;
}
row.push(cell);
rows.push(row);
row = [];
cell = "";
continue;
}
cell += char;
}
row.push(cell);
rows.push(row);
return rows;
}
function normalizeCell(value) {
const trimmed = String(value ?? "").trim();
if (trimmed === "") {
return null;
}
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
const numeric = Number(trimmed);
if (Number.isFinite(numeric)) {
return numeric;
}
}
return trimmed;
}
function normalizeOracleGameRows(rows, options = {}) {
const filteredLeague = options.league || "LCK";
const normalized = rows
.filter((row) => !filteredLeague || String(row.league || row.League || "").toUpperCase() === String(filteredLeague).toUpperCase())
.map((row) => {
const teamQuery = resolveTeamQuery(row.teamname || row.Team || row.team || row.team_name);
const opponentQuery = resolveTeamQuery(row.opponentteam || row.opponent || row.Opponent || row.opponent_team);
const patch = String(row.patch || row.gameversion || row.gameVersion || "");
const champion = row.champion || row.Champion || null;
const position = String(row.position || row.Pos || row.pos || "").toLowerCase() || null;
const result = normalizeResult(row.result ?? row.Result);
return {
matchId: String(row.matchid || row.MatchId || row.match_id || row.gameid || row.GameId || `${row.date || row.Date}-${teamQuery.currentName}-${champion}`),
date: String(row.date || row.Date || ""),
patch,
side: normalizeSide(row.side || row.Side),
team: {
input: teamQuery.input,
canonicalId: teamQuery.canonicalId,
name: teamQuery.currentName,
},
opponent: {
input: opponentQuery.input,
canonicalId: opponentQuery.canonicalId,
name: opponentQuery.currentName,
},
playerName: row.playername || row.Player || row.player || null,
position,
champion,
opponentChampion: row.opponentchampion || row.opponent_champion || row.OpponentChampion || null,
result,
goldDiffAt15: toNumber(row.gd15 ?? row.GD15 ?? row.goldDiffAt15),
csDiffAt15: toNumber(row.csd15 ?? row.CSD15 ?? row.csDiffAt15),
xpDiffAt15: toNumber(row.xpd15 ?? row.XPD15 ?? row.xpDiffAt15),
firstBloodRate: toNumber(row.fb ?? row["FB%"] ?? row.firstBloodRate),
dragonControlRate: toNumber(row.drg ?? row["DRG%"] ?? row.dragonControlRate),
baronControlRate: toNumber(row.bn ?? row["BN%"] ?? row.baronControlRate),
towerControlRate: toNumber(row.ft ?? row["FT%"] ?? row.towerControlRate),
kills: toNumber(row.kills ?? row.K),
deaths: toNumber(row.deaths ?? row.D),
assists: toNumber(row.assists ?? row.A),
gamesPlayed: toNumber(row.gamesplayed ?? row.GP) ?? 1,
blindPick: toBooleanLike(row.blindpick ?? row["BLND%"] ?? row.blindPick),
counterPick: toBooleanLike(row.counterpick ?? row["CTR%"] ?? row.counterPick),
};
})
.filter((row) => row.team.canonicalId && row.opponent.canonicalId && row.position && row.champion);
return normalized;
}
function buildHistoricalDataset(rows, options = {}) {
const normalizedRows = normalizeOracleGameRows(rows, options);
return {
rows: normalizedRows,
teamPowerRatings: buildTeamPowerRatings(normalizedRows),
championStats: buildChampionPatchStats(normalizedRows),
matchupStats: buildChampionMatchups(normalizedRows),
synergyStats: buildChampionSynergies(normalizedRows),
patchMeta: buildPatchMetaSummaries(normalizedRows),
};
}
function buildTeamPowerRatings(rows) {
const grouped = new Map();
for (const row of rows) {
const key = row.team.canonicalId;
if (!grouped.has(key)) {
grouped.set(key, []);
}
grouped.get(key).push(row);
}
return [...grouped.entries()].map(([teamId, teamRows]) => {
const wins = teamRows.filter((row) => row.result === "win").length;
const losses = teamRows.filter((row) => row.result === "loss").length;
const games = teamRows.length;
const recentRows = teamRows.slice(-5);
const recentWins = recentRows.filter((row) => row.result === "win").length;
const avgGold15 = average(teamRows.map((row) => row.goldDiffAt15));
const avgDragon = average(teamRows.map((row) => row.dragonControlRate));
const avgBaron = average(teamRows.map((row) => row.baronControlRate));
const weightedScore = round(
((wins / Math.max(games, 1)) * 60)
+ (normalizeScale(avgGold15, -3000, 3000) * 20)
+ (normalizeScale(avgDragon, 0, 100) * 10)
+ (normalizeScale(avgBaron, 0, 100) * 10),
2,
);
return {
teamId,
games,
wins,
losses,
recentWins,
recentGames: recentRows.length,
avgGoldDiffAt15: avgGold15,
avgDragonControlRate: avgDragon,
avgBaronControlRate: avgBaron,
powerScore: weightedScore,
tier: weightedScore >= 75 ? "elite" : weightedScore >= 60 ? "strong" : weightedScore >= 45 ? "average" : "developing",
};
}).sort((left, right) => right.powerScore - left.powerScore);
}
function buildChampionPatchStats(rows) {
const grouped = new Map();
for (const row of rows) {
const key = `${row.patch}::${row.position}::${row.champion}`;
if (!grouped.has(key)) {
grouped.set(key, []);
}
grouped.get(key).push(row);
}
return [...grouped.entries()].map(([key, championRows]) => {
const [patch, position, champion] = key.split("::");
return {
patch,
position,
champion,
games: championRows.length,
wins: championRows.filter((row) => row.result === "win").length,
winRate: round(rate(championRows.filter((row) => row.result === "win").length, championRows.length), 2),
avgGoldDiffAt15: average(championRows.map((row) => row.goldDiffAt15)),
avgCsDiffAt15: average(championRows.map((row) => row.csDiffAt15)),
avgXpDiffAt15: average(championRows.map((row) => row.xpDiffAt15)),
blindPickRate: round(rate(championRows.filter((row) => row.blindPick === true).length, championRows.length), 2),
counterPickRate: round(rate(championRows.filter((row) => row.counterPick === true).length, championRows.length), 2),
};
}).sort((left, right) => {
if (left.patch !== right.patch) {
return left.patch.localeCompare(right.patch);
}
return right.games - left.games;
});
}
function buildChampionMatchups(rows) {
const grouped = new Map();
for (const row of rows) {
if (!row.opponentChampion) {
continue;
}
const key = `${row.patch}::${row.position}::${row.champion}::${row.opponentChampion}`;
if (!grouped.has(key)) {
grouped.set(key, []);
}
grouped.get(key).push(row);
}
return [...grouped.entries()].map(([key, matchupRows]) => {
const [patch, position, champion, opponentChampion] = key.split("::");
const wins = matchupRows.filter((row) => row.result === "win").length;
return {
patch,
position,
champion,
opponentChampion,
games: matchupRows.length,
wins,
winRate: round(rate(wins, matchupRows.length), 2),
avgGoldDiffAt15: average(matchupRows.map((row) => row.goldDiffAt15)),
avgCsDiffAt15: average(matchupRows.map((row) => row.csDiffAt15)),
counterPickRate: round(rate(matchupRows.filter((row) => row.counterPick === true).length, matchupRows.length), 2),
};
}).sort((left, right) => right.games - left.games);
}
function buildChampionSynergies(rows) {
const games = new Map();
for (const row of rows) {
const key = `${row.matchId}::${row.team.canonicalId}`;
if (!games.has(key)) {
games.set(key, []);
}
games.get(key).push(row);
}
const synergy = new Map();
for (const gameRows of games.values()) {
for (let index = 0; index < gameRows.length; index += 1) {
for (let inner = index + 1; inner < gameRows.length; inner += 1) {
const left = gameRows[index];
const right = gameRows[inner];
const pair = [left.champion, right.champion].sort();
const key = `${left.patch}::${pair[0]}::${pair[1]}`;
if (!synergy.has(key)) {
synergy.set(key, []);
}
synergy.get(key).push(left.result === "win" ? 1 : 0);
}
}
}
return [...synergy.entries()].map(([key, results]) => {
const [patch, championA, championB] = key.split("::");
const wins = results.reduce((sum, value) => sum + value, 0);
return {
patch,
championA,
championB,
games: results.length,
wins,
winRate: round(rate(wins, results.length), 2),
};
}).sort((left, right) => right.games - left.games);
}
function buildPatchMetaSummaries(rows) {
const championStats = buildChampionPatchStats(rows);
const byPatch = new Map();
for (const stat of championStats) {
if (!byPatch.has(stat.patch)) {
byPatch.set(stat.patch, []);
}
byPatch.get(stat.patch).push(stat);
}
return [...byPatch.entries()].map(([patch, stats], index, all) => {
const sorted = [...stats].sort((left, right) => right.games - left.games);
const topPicks = sorted.slice(0, 5).map((entry) => ({
champion: entry.champion,
position: entry.position,
games: entry.games,
winRate: entry.winRate,
}));
const previousPatch = all[index - 1]?.[0] || null;
const previousStats = previousPatch ? byPatch.get(previousPatch) || [] : [];
const risers = topPicks.map((pick) => {
const previous = previousStats.find((entry) => entry.champion === pick.champion && entry.position === pick.position);
return {
...pick,
gameDelta: pick.games - (previous?.games || 0),
};
}).sort((left, right) => right.gameDelta - left.gameDelta);
return {
patch,
topPicks,
risers: risers.slice(0, 3),
};
}).sort((left, right) => left.patch.localeCompare(right.patch));
}
function normalizeResult(value) {
const normalized = String(value ?? "").trim().toLowerCase();
if (["1", "win", "w", "true"].includes(normalized)) {
return "win";
}
if (["0", "loss", "lose", "l", "false"].includes(normalized)) {
return "loss";
}
return null;
}
function normalizeSide(value) {
const normalized = String(value ?? "").trim().toLowerCase();
if (["blue", "b", "100"].includes(normalized)) {
return "blue";
}
if (["red", "r", "200"].includes(normalized)) {
return "red";
}
return null;
}
function toNumber(value) {
if (value === null || value === undefined || value === "") {
return null;
}
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function toBooleanLike(value) {
if (typeof value === "boolean") {
return value;
}
const numeric = toNumber(value);
if (numeric === null) {
return null;
}
if (numeric === 0) {
return false;
}
if (numeric === 1) {
return true;
}
return numeric >= 50;
}
function average(values) {
const filtered = values.filter((value) => Number.isFinite(value));
if (filtered.length === 0) {
return null;
}
return round(filtered.reduce((sum, value) => sum + value, 0) / filtered.length, 2);
}
function rate(numerator, denominator) {
if (!denominator) {
return 0;
}
return (numerator / denominator) * 100;
}
function normalizeScale(value, min, max) {
if (!Number.isFinite(value)) {
return 0;
}
if (max <= min) {
return 0;
}
const clamped = Math.min(max, Math.max(min, value));
return (clamped - min) / (max - min);
}
function round(value, digits = 2) {
if (!Number.isFinite(value)) {
return null;
}
const scale = 10 ** digits;
return Math.round(value * scale) / scale;
}
module.exports = {
buildChampionMatchups,
buildChampionPatchStats,
buildChampionSynergies,
buildHistoricalDataset,
buildPatchMetaSummaries,
buildTeamPowerRatings,
normalizeOracleGameRows,
parseOracleCsv,
};

View file

@ -0,0 +1,444 @@
const { STATUS_MAP } = require("./constants");
const {
resolveTeamPayload,
resolveTeamQuery,
stripAliasTokens,
} = require("./teams");
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 {
year: parts.year,
month: parts.month,
day: parts.day,
isoDate: `${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.");
}
const [year, month, day] = [match[1], match[2], match[3]];
const candidate = new Date(`${year}-${month}-${day}T00:00:00+09:00`);
if (Number.isNaN(candidate.getTime())) {
throw new Error("date must be a valid Date or YYYY-MM-DD string.");
}
return {
year,
month,
day,
isoDate: `${year}-${month}-${day}`,
};
}
function eventToKoreaDateTime(startTime) {
const date = new Date(startTime);
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Seoul",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).formatToParts(date).reduce((acc, part) => {
if (part.type !== "literal") {
acc[part.type] = part.value;
}
return acc;
}, {});
return {
date: `${parts.year}-${parts.month}-${parts.day}`,
kickOff: `${parts.hour}:${parts.minute}`,
};
}
function normalizeMatchStatus(state) {
const mapped = STATUS_MAP[state] || {
state: String(state || "unknown"),
label: String(state || "알 수 없음"),
finished: false,
};
return {
code: state || "unknown",
state: mapped.state,
label: mapped.label,
finished: mapped.finished,
};
}
function normalizeScheduleResponse(payload, options = {}) {
const schedule = payload?.data?.schedule || payload?.schedule || payload || {};
const requestedDate = normalizeDateInput(options.date);
const requestedTeam = options.team ? resolveTeamQuery(options.team) : null;
const events = Array.isArray(schedule.events) ? schedule.events : [];
const matches = events
.filter((event) => event?.league?.slug === "lck")
.map(normalizeEventMatch)
.filter((match) => match.date === requestedDate.isoDate)
.filter((match) => !requestedTeam || matchIncludesRequestedTeam(match, requestedTeam))
.sort(compareMatches);
return {
queryDate: requestedDate.isoDate,
filteredTeam: requestedTeam
? {
input: requestedTeam.input,
canonicalId: requestedTeam.canonicalId,
normalized: requestedTeam.currentName,
}
: null,
matches,
pageInfo: {
older: schedule.pages?.older || null,
newer: schedule.pages?.newer || null,
},
};
}
function normalizeEventMatch(event) {
const teams = Array.isArray(event?.match?.teams) ? event.match.teams.map(resolveTeamPayload) : [];
const [team1, team2] = teams;
const koreaTime = eventToKoreaDateTime(event.startTime);
const status = normalizeMatchStatus(event.state || event?.match?.state);
const score = {
team1: normalizeNumber(team1?.result?.gameWins ?? event?.match?.teams?.[0]?.result?.gameWins),
team2: normalizeNumber(team2?.result?.gameWins ?? event?.match?.teams?.[1]?.result?.gameWins),
};
return {
matchId: event?.match?.id || event?.id || null,
eventId: event?.id || null,
league: event?.league?.name || "LCK",
leagueSlug: event?.league?.slug || "lck",
tournamentId: event?.tournament?.id || null,
tournamentName: event?.tournament?.name || null,
blockName: event?.blockName || null,
date: koreaTime.date,
kickOff: koreaTime.kickOff,
startTime: event?.startTime || null,
status,
strategy: event?.match?.strategy || null,
team1: team1 ? stripAliasTokens(team1) : null,
team2: team2 ? stripAliasTokens(team2) : null,
score,
winner: determineWinner(team1, team2, score, status),
flags: Array.isArray(event?.match?.flags) ? event.match.flags : [],
};
}
function normalizeStandingsResponse(payload, options = {}) {
const standings = Array.isArray(payload?.data?.standings) ? payload.data.standings : [];
const requestedTeam = options.team ? resolveTeamQuery(options.team) : null;
const tournament = options.tournament || null;
const stage = standings.flatMap((entry) => entry?.stages || []).find((candidate) => Array.isArray(candidate?.sections));
const section = stage?.sections?.find((candidate) => Array.isArray(candidate?.rankings)) || null;
const rows = [];
for (const ranking of section?.rankings || []) {
for (const team of ranking.teams || []) {
const resolved = resolveTeamPayload(team);
const row = {
rank: normalizeNumber(ranking.ordinal),
team: stripAliasTokens(resolved),
wins: normalizeNumber(team?.record?.wins) ?? 0,
losses: normalizeNumber(team?.record?.losses) ?? 0,
};
if (!requestedTeam || teamMatchesRequestedTeam(resolved, requestedTeam)) {
rows.push(row);
}
}
}
rows.sort((left, right) => {
if (left.rank !== right.rank) {
return left.rank - right.rank;
}
return left.team.name.localeCompare(right.team.name, "en");
});
return {
tournamentId: tournament?.id || null,
tournamentName: tournament?.slug || tournament?.name || null,
stage: stage
? {
id: stage.id,
name: stage.name,
slug: stage.slug,
}
: null,
sectionName: section?.name || null,
filteredTeam: requestedTeam
? {
input: requestedTeam.input,
canonicalId: requestedTeam.canonicalId,
normalized: requestedTeam.currentName,
}
: null,
rows,
};
}
function normalizeTournamentList(payload) {
const tournaments = payload?.data?.leagues?.flatMap((league) => league?.tournaments || []) || [];
return tournaments
.map((tournament) => ({
id: tournament.id,
slug: tournament.slug,
startDate: tournament.startDate,
endDate: tournament.endDate,
}))
.sort((left, right) => left.startDate.localeCompare(right.startDate));
}
function normalizeEventDetailsResponse(payload) {
const event = payload?.data?.event || payload?.event || {};
const matchTeams = Array.isArray(event?.match?.teams) ? event.match.teams.map(resolveTeamPayload) : [];
const byId = new Map(matchTeams.map((team) => [String(team.id), team]));
return {
eventId: event?.id || null,
streams: Array.isArray(event?.streams) ? event.streams : [],
games: (event?.match?.games || []).map((game) => ({
id: game?.id || null,
number: normalizeNumber(game?.number),
state: game?.state || null,
teams: (game?.teams || []).map((team) => {
const resolved = byId.get(String(team?.id)) || resolveTeamPayload(team);
return {
side: team?.side || null,
team: stripAliasTokens(resolved),
};
}),
})),
};
}
function normalizeLiveGameResponse(windowPayload, detailsPayload, options = {}) {
const gameMetadata = windowPayload?.gameMetadata || {};
const windowFrames = Array.isArray(windowPayload?.frames) ? windowPayload.frames : [];
const detailsFrames = Array.isArray(detailsPayload?.frames) ? detailsPayload.frames : [];
const latestWindow = windowFrames.at(-1) || null;
const latestDetails = detailsFrames.at(-1) || null;
if (!latestWindow && !latestDetails) {
return null;
}
const participantDirectory = buildParticipantDirectory(gameMetadata, latestWindow, latestDetails);
const blue = normalizeLiveSide("blue", latestWindow?.blueTeam, participantDirectory);
const red = normalizeLiveSide("red", latestWindow?.redTeam, participantDirectory);
const firstTimestamp = windowFrames[0]?.rfc460Timestamp || detailsFrames[0]?.rfc460Timestamp || null;
const lastTimestamp = latestWindow?.rfc460Timestamp || latestDetails?.rfc460Timestamp || null;
const normalized = {
gameId: options.gameId || windowPayload?.esportsGameId || null,
matchId: windowPayload?.esportsMatchId || options.matchId || null,
patchVersion: gameMetadata.patchVersion || null,
gameState: latestWindow?.gameState || null,
updatedAt: lastTimestamp,
durationSeconds: firstTimestamp && lastTimestamp
? Math.max(0, Math.round((new Date(lastTimestamp) - new Date(firstTimestamp)) / 1000))
: null,
blueTeam: blue,
redTeam: red,
goldDiff: blue.totalGold !== null && red.totalGold !== null ? blue.totalGold - red.totalGold : null,
killDiff: blue.totalKills !== null && red.totalKills !== null ? blue.totalKills - red.totalKills : null,
};
return hasMeaningfulLiveStats(normalized) ? normalized : null;
}
function hasMeaningfulLiveStats(live) {
if (!live) {
return false;
}
if (live.gameState && live.gameState !== "in_game") {
return true;
}
const teamSignals = [live.blueTeam, live.redTeam].some((team) => {
if (!team) {
return false;
}
return (team.totalGold ?? 0) > 0
|| (team.totalKills ?? 0) > 0
|| (team.towers ?? 0) > 0
|| (team.barons ?? 0) > 0
|| (team.dragonCount ?? 0) > 0
|| team.participants.some((participant) =>
(participant.level ?? 0) > 1
|| (participant.creepScore ?? 0) > 0
|| (participant.totalGold ?? 0) > 0
|| (participant.kills ?? 0) > 0
|| (participant.deaths ?? 0) > 0
|| (participant.assists ?? 0) > 0
|| participant.items.length > 0,
);
});
return teamSignals;
}
function buildParticipantDirectory(gameMetadata, latestWindow, latestDetails) {
const directory = new Map();
const sideMaps = [
["blue", gameMetadata?.blueTeamMetadata?.participantMetadata || [], latestWindow?.blueTeam?.participants || []],
["red", gameMetadata?.redTeamMetadata?.participantMetadata || [], latestWindow?.redTeam?.participants || []],
];
const detailParticipants = new Map((latestDetails?.participants || []).map((participant) => [participant.participantId, participant]));
for (const [side, metadataRows, windowRows] of sideMaps) {
const windowParticipants = new Map(windowRows.map((participant) => [participant.participantId, participant]));
for (const metadata of metadataRows) {
directory.set(metadata.participantId, {
side,
participantId: metadata.participantId,
esportsPlayerId: metadata.esportsPlayerId || null,
summonerName: metadata.summonerName || null,
championId: metadata.championId || null,
role: metadata.role || null,
window: windowParticipants.get(metadata.participantId) || null,
details: detailParticipants.get(metadata.participantId) || null,
});
}
}
return directory;
}
function normalizeLiveSide(side, teamSnapshot, participantDirectory) {
const participants = [...participantDirectory.values()]
.filter((participant) => participant.side === side)
.sort((left, right) => left.participantId - right.participantId)
.map((participant) => ({
participantId: participant.participantId,
esportsPlayerId: participant.esportsPlayerId,
summonerName: participant.summonerName,
championId: participant.championId,
role: participant.role,
level: normalizeNumber(participant.window?.level ?? participant.details?.level),
kills: normalizeNumber(participant.window?.kills ?? participant.details?.kills),
deaths: normalizeNumber(participant.window?.deaths ?? participant.details?.deaths),
assists: normalizeNumber(participant.window?.assists ?? participant.details?.assists),
creepScore: normalizeNumber(participant.window?.creepScore ?? participant.details?.creepScore),
totalGold: normalizeNumber(participant.window?.totalGold ?? participant.details?.totalGoldEarned),
items: Array.isArray(participant.details?.items) ? participant.details.items : [],
}));
return {
side,
totalGold: normalizeNumber(teamSnapshot?.totalGold),
totalKills: normalizeNumber(teamSnapshot?.totalKills),
towers: normalizeNumber(teamSnapshot?.towers),
inhibitors: normalizeNumber(teamSnapshot?.inhibitors),
barons: normalizeNumber(teamSnapshot?.barons),
dragons: Array.isArray(teamSnapshot?.dragons) ? teamSnapshot.dragons : [],
dragonCount: Array.isArray(teamSnapshot?.dragons) ? teamSnapshot.dragons.length : 0,
participants,
};
}
function resolveTournamentForDate(tournaments, date) {
const requestedDate = normalizeDateInput(date).isoDate;
const direct = tournaments.find((tournament) => tournament.startDate <= requestedDate && requestedDate <= tournament.endDate);
if (direct) {
return direct;
}
const older = tournaments.filter((tournament) => tournament.startDate <= requestedDate);
return older.at(-1) || tournaments[0] || null;
}
function matchIncludesRequestedTeam(match, requestedTeam) {
return teamMatchesRequestedTeam(match.team1, requestedTeam) || teamMatchesRequestedTeam(match.team2, requestedTeam);
}
function teamMatchesRequestedTeam(team, requestedTeam) {
if (!team) {
return false;
}
const resolved = resolveTeamPayload(team);
if (requestedTeam.canonicalId && resolved.canonicalId) {
return requestedTeam.canonicalId === resolved.canonicalId;
}
return resolved.aliasTokens.has(requestedTeam.token);
}
function determineWinner(team1, team2, score, status) {
if (!status.finished || score.team1 === null || score.team2 === null) {
return null;
}
if (score.team1 === score.team2) {
return "draw";
}
return score.team1 > score.team2 ? team1?.canonicalId || team1?.code || "team1" : team2?.canonicalId || team2?.code || "team2";
}
function compareMatches(left, right) {
const leftKey = `${left.date}T${left.kickOff}`;
const rightKey = `${right.date}T${right.kickOff}`;
if (leftKey !== rightKey) {
return leftKey.localeCompare(rightKey);
}
return String(left.matchId || "").localeCompare(String(right.matchId || ""));
}
function normalizeNumber(value) {
if (value === null || value === undefined || value === "") {
return null;
}
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
module.exports = {
buildParticipantDirectory,
compareMatches,
eventToKoreaDateTime,
normalizeDateInput,
hasMeaningfulLiveStats,
normalizeEventDetailsResponse,
normalizeEventMatch,
normalizeLiveGameResponse,
normalizeLiveSide,
normalizeMatchStatus,
normalizeScheduleResponse,
normalizeStandingsResponse,
normalizeTournamentList,
resolveTournamentForDate,
};

View file

@ -0,0 +1,189 @@
function normalizeToken(value) {
return String(value || "")
.normalize("NFKC")
.toUpperCase()
.replace(/[^0-9A-Z가-힣]+/g, "");
}
const TEAM_REGISTRY = [
{
canonicalId: "hle",
teamIds: ["100205573496804586"],
currentName: "Hanwha Life Esports",
aliases: ["Hanwha Life Esports", "Hanwha", "HLE", "한화", "한화생명", "한화생명e스포츠"],
},
{
canonicalId: "gen",
teamIds: ["100205573495116443"],
currentName: "Gen.G Esports",
aliases: ["Gen.G", "Gen.G Esports", "GEN", "젠지", "젠지 e스포츠"],
},
{
canonicalId: "t1",
teamIds: ["98767991853197861"],
currentName: "T1",
aliases: ["T1", "SKT", "SKT T1", "SK Telecom T1", "SK텔레콤 T1"],
},
{
canonicalId: "dk",
teamIds: ["100725845018863243"],
currentName: "Dplus KIA",
aliases: ["Dplus KIA", "DK", "Damwon KIA", "DWG KIA", "DAMWON Gaming", "담원", "담원 기아", "디플러스 기아"],
},
{
canonicalId: "kt",
teamIds: ["99566404579461230"],
currentName: "kt Rolster",
aliases: ["kt Rolster", "KT", "KT Rolster", "케이티", "케이티 롤스터"],
},
{
canonicalId: "ns",
teamIds: ["102747101565183056"],
currentName: "NONGSHIM RED FORCE",
aliases: ["NONGSHIM RED FORCE", "Nongshim", "NS", "농심", "농심 레드포스", "Team Dynamics"],
},
{
canonicalId: "bro",
teamIds: ["105505619546859895"],
currentName: "HANJIN BRION",
aliases: ["HANJIN BRION", "BRION", "BRO", "OKSavingsBank BRION", "Fredit BRION", "브리온", "한진 브리온"],
},
{
canonicalId: "bfx",
teamIds: ["100725845022060229"],
currentName: "BNK FEARX",
aliases: ["BNK FEARX", "FEARX", "BFX", "Liiv SANDBOX", "SANDBOX Gaming", "리브 샌드박스", "샌드박스", "피어엑스"],
},
{
canonicalId: "drx",
teamIds: ["99566404585387054"],
currentName: "KIWOOM DRX",
aliases: ["KIWOOM DRX", "DRX", "DragonX", "Kingzone DragonX", "킹존 드래곤X", "드래곤X", "키움 DRX"],
},
{
canonicalId: "dnf",
teamIds: ["99566404581868574"],
currentName: "DN SOOPers",
aliases: [
"DN SOOPers",
"DNS",
"DN FREECS",
"DNF",
"Kwangdong Freecs",
"KDF",
"광동 프릭스",
"광동",
"Afreeca Freecs",
"아프리카 프릭스",
"Freecs"
],
},
];
const REGISTRY_BY_ID = new Map();
const REGISTRY_BY_TOKEN = new Map();
for (const entry of TEAM_REGISTRY) {
entry.aliasTokens = new Set();
for (const tokenSource of [entry.canonicalId, entry.currentName, ...(entry.aliases || [])]) {
const token = normalizeToken(tokenSource);
if (!token) {
continue;
}
entry.aliasTokens.add(token);
REGISTRY_BY_TOKEN.set(token, entry);
}
for (const teamId of entry.teamIds || []) {
REGISTRY_BY_ID.set(String(teamId), entry);
}
}
function resolveTeamQuery(query) {
const input = String(query || "").trim();
const token = normalizeToken(input);
const entry = REGISTRY_BY_TOKEN.get(token);
if (!entry) {
return {
input,
token,
canonicalId: null,
currentName: input,
aliasTokens: new Set(token ? [token] : []),
};
}
return {
input,
token,
canonicalId: entry.canonicalId,
currentName: entry.currentName,
aliasTokens: new Set(entry.aliasTokens),
};
}
function resolveTeamPayload(team) {
const teamId = String(team?.id || "");
const entry = REGISTRY_BY_ID.get(teamId)
|| REGISTRY_BY_TOKEN.get(normalizeToken(team?.name))
|| REGISTRY_BY_TOKEN.get(normalizeToken(team?.code))
|| REGISTRY_BY_TOKEN.get(normalizeToken(team?.slug));
if (!entry) {
return {
id: team?.id || null,
slug: team?.slug || null,
code: team?.code || null,
name: team?.name || null,
image: team?.image || null,
canonicalId: null,
currentName: team?.name || null,
aliasTokens: new Set([
normalizeToken(team?.id),
normalizeToken(team?.slug),
normalizeToken(team?.code),
normalizeToken(team?.name),
].filter(Boolean)),
};
}
return {
id: team?.id || null,
slug: team?.slug || null,
code: team?.code || null,
name: team?.name || entry.currentName,
image: team?.image || null,
canonicalId: entry.canonicalId,
currentName: entry.currentName,
aliasTokens: new Set([
normalizeToken(team?.id),
normalizeToken(team?.slug),
normalizeToken(team?.code),
normalizeToken(team?.name),
...entry.aliasTokens,
].filter(Boolean)),
};
}
function stripAliasTokens(team) {
return {
id: team.id,
slug: team.slug,
code: team.code,
name: team.name,
image: team.image,
canonicalId: team.canonicalId,
currentName: team.currentName,
};
}
module.exports = {
TEAM_REGISTRY,
normalizeToken,
resolveTeamPayload,
resolveTeamQuery,
stripAliasTokens,
};

View file

@ -0,0 +1,47 @@
{
"data": {
"event": {
"id": "match-1",
"streams": [
{
"parameter": "lck",
"locale": "ko-KR",
"provider": "afreeca"
}
],
"match": {
"games": [
{
"id": "game-1",
"number": 1,
"state": "inProgress",
"teams": [
{
"id": "100205573496804586",
"side": "blue"
},
{
"id": "98767991853197861",
"side": "red"
}
]
}
],
"teams": [
{
"id": "100205573496804586",
"name": "Hanwha Life Esports",
"code": "HLE",
"slug": "hanwha-life-esports"
},
{
"id": "98767991853197861",
"name": "T1",
"code": "T1",
"slug": "t1"
}
]
}
}
}
}

View file

@ -0,0 +1,88 @@
{
"esportsGameId": "game-1",
"esportsMatchId": "match-1",
"frames": [
{
"rfc460Timestamp": "2026-04-01T09:00:00.000Z",
"participants": [
{
"participantId": 1,
"level": 12,
"kills": 2,
"deaths": 0,
"assists": 4,
"creepScore": 138,
"currentGold": 1200
},
{
"participantId": 2,
"level": 10,
"kills": 0,
"deaths": 2,
"assists": 1,
"creepScore": 112,
"currentGold": 800
},
{
"participantId": 6,
"level": 11,
"kills": 1,
"deaths": 1,
"assists": 0,
"creepScore": 126,
"currentGold": 950
},
{
"participantId": 7,
"level": 9,
"kills": 0,
"deaths": 2,
"assists": 1,
"creepScore": 101,
"currentGold": 700
}
]
},
{
"rfc460Timestamp": "2026-04-01T09:05:00.000Z",
"participants": [
{
"participantId": 1,
"level": 14,
"kills": 5,
"deaths": 0,
"assists": 6,
"creepScore": 165,
"currentGold": 2100
},
{
"participantId": 2,
"level": 12,
"kills": 0,
"deaths": 4,
"assists": 2,
"creepScore": 130,
"currentGold": 900
},
{
"participantId": 6,
"level": 12,
"kills": 1,
"deaths": 3,
"assists": 0,
"creepScore": 140,
"currentGold": 1100
},
{
"participantId": 7,
"level": 11,
"kills": 0,
"deaths": 3,
"assists": 1,
"creepScore": 119,
"currentGold": 750
}
]
}
]
}

View file

@ -0,0 +1,118 @@
{
"esportsGameId": "game-1",
"esportsMatchId": "match-1",
"gameMetadata": {
"patchVersion": "16.6.753.8272",
"blueTeamMetadata": {
"participantMetadata": [
{
"participantId": 1,
"summonerName": "HLE Zeus",
"championId": "Aatrox",
"role": "top"
},
{
"participantId": 2,
"summonerName": "HLE Peanut",
"championId": "Vi",
"role": "jungle"
}
]
},
"redTeamMetadata": {
"participantMetadata": [
{
"participantId": 6,
"summonerName": "T1 Doran",
"championId": "Gnar",
"role": "top"
},
{
"participantId": 7,
"summonerName": "T1 Oner",
"championId": "Sejuani",
"role": "jungle"
}
]
}
},
"frames": [
{
"rfc460Timestamp": "2026-04-01T09:00:00.000Z",
"gameState": "in_game",
"blueTeam": {
"totalGold": 24000,
"inhibitors": 0,
"towers": 3,
"barons": 0,
"totalKills": 2,
"dragons": [
"infernal"
],
"participants": [
{
"participantId": 1
},
{
"participantId": 2
}
]
},
"redTeam": {
"totalGold": 23200,
"inhibitors": 0,
"towers": 2,
"barons": 0,
"totalKills": 1,
"dragons": [],
"participants": [
{
"participantId": 6
},
{
"participantId": 7
}
]
}
},
{
"rfc460Timestamp": "2026-04-01T09:05:00.000Z",
"gameState": "in_game",
"blueTeam": {
"totalGold": 29800,
"inhibitors": 0,
"towers": 5,
"barons": 1,
"totalKills": 6,
"dragons": [
"infernal",
"hextech"
],
"participants": [
{
"participantId": 1
},
{
"participantId": 2
}
]
},
"redTeam": {
"totalGold": 26200,
"inhibitors": 0,
"towers": 2,
"barons": 0,
"totalKills": 1,
"dragons": [],
"participants": [
{
"participantId": 6
},
{
"participantId": 7
}
]
}
}
]
}

View file

@ -0,0 +1,5 @@
league,matchid,date,patch,side,teamname,opponentteam,playername,position,champion,opponentchampion,result,gd15,csd15,xpd15,drg,bn,blindpick,counterpick
LCK,match-1,2026-04-01,16.6.753.8272,blue,Hanwha Life Esports,T1,HLE Zeus,top,Aatrox,Gnar,win,1200,18,340,100,100,0,1
LCK,match-1,2026-04-01,16.6.753.8272,blue,Hanwha Life Esports,T1,HLE Peanut,jungle,Vi,Sejuani,win,800,5,280,100,100,1,0
LCK,match-1,2026-04-01,16.6.753.8272,red,T1,Hanwha Life Esports,T1 Doran,top,Gnar,Aatrox,loss,-1200,-18,-340,0,0,1,0
LCK,match-1,2026-04-01,16.6.753.8272,red,T1,Hanwha Life Esports,T1 Oner,jungle,Sejuani,Vi,loss,-800,-5,-280,0,0,0,1
1 league matchid date patch side teamname opponentteam playername position champion opponentchampion result gd15 csd15 xpd15 drg bn blindpick counterpick
2 LCK match-1 2026-04-01 16.6.753.8272 blue Hanwha Life Esports T1 HLE Zeus top Aatrox Gnar win 1200 18 340 100 100 0 1
3 LCK match-1 2026-04-01 16.6.753.8272 blue Hanwha Life Esports T1 HLE Peanut jungle Vi Sejuani win 800 5 280 100 100 1 0
4 LCK match-1 2026-04-01 16.6.753.8272 red T1 Hanwha Life Esports T1 Doran top Gnar Aatrox loss -1200 -18 -340 0 0 1 0
5 LCK match-1 2026-04-01 16.6.753.8272 red T1 Hanwha Life Esports T1 Oner jungle Sejuani Vi loss -800 -5 -280 0 0 0 1

View file

@ -0,0 +1,57 @@
{
"data": {
"schedule": {
"pages": {
"older": null,
"newer": null
},
"events": [
{
"id": "event-1",
"startTime": "2026-04-01T09:00:00.000Z",
"state": "inProgress",
"blockName": "Regular Season",
"league": {
"slug": "lck",
"name": "LCK"
},
"tournament": {
"id": "tournament-2026",
"name": "LCK 2026 Spring",
"slug": "lck-2026-spring"
},
"match": {
"id": "match-1",
"strategy": {
"type": "bestOf",
"count": 3
},
"teams": [
{
"id": "100205573496804586",
"name": "Hanwha Life Esports",
"code": "HLE",
"slug": "hanwha-life-esports",
"result": {
"gameWins": 1
}
},
{
"id": "98767991853197861",
"name": "T1",
"code": "T1",
"slug": "t1",
"result": {
"gameWins": 0
}
}
],
"flags": [
"featured"
]
}
}
]
}
}
}

View file

@ -0,0 +1,52 @@
{
"data": {
"standings": [
{
"stages": [
{
"id": "stage-1",
"name": "Regular Season",
"slug": "regular-season",
"sections": [
{
"name": "Rankings",
"rankings": [
{
"ordinal": 1,
"teams": [
{
"id": "100205573496804586",
"name": "Hanwha Life Esports",
"code": "HLE",
"slug": "hanwha-life-esports",
"record": {
"wins": 5,
"losses": 1
}
}
]
},
{
"ordinal": 2,
"teams": [
{
"id": "98767991853197861",
"name": "T1",
"code": "T1",
"slug": "t1",
"record": {
"wins": 4,
"losses": 2
}
}
]
}
]
}
]
}
]
}
]
}
}

View file

@ -0,0 +1,18 @@
{
"data": {
"leagues": [
{
"id": "98767991310872058",
"slug": "lck",
"tournaments": [
{
"id": "tournament-2026",
"slug": "lck-2026-spring",
"startDate": "2026-03-01",
"endDate": "2026-05-01"
}
]
}
]
}
}

View file

@ -0,0 +1,170 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const {
buildHistoricalAnalytics,
getGameAnalysis,
getLckSummary,
getMatchAnalysis,
getMatchResults,
getStandings,
parseOracleCsv,
} = require("../src/index");
const {
normalizeScheduleResponse,
normalizeStandingsResponse,
} = require("../src/parse");
const fixturesDir = path.join(__dirname, "fixtures");
function readFixture(name) {
return JSON.parse(fs.readFileSync(path.join(fixturesDir, name), "utf8"));
}
const schedulePayload = readFixture("schedule-2026-04-01.json");
const tournamentsPayload = readFixture("tournaments-2026.json");
const standingsPayload = readFixture("standings-2026.json");
const eventDetailsPayload = readFixture("event-details-2026-04-01.json");
const liveWindowPayload = readFixture("live-window-game-1.json");
const liveDetailsPayload = readFixture("live-details-game-1.json");
const oracleCsv = fs.readFileSync(path.join(fixturesDir, "oracle-sample.csv"), "utf8");
test("normalizeScheduleResponse filters requested LCK date and Korean team aliases", () => {
const result = normalizeScheduleResponse(schedulePayload, {
date: "2026-04-01",
team: "한화",
});
assert.equal(result.queryDate, "2026-04-01");
assert.equal(result.matches.length, 1);
assert.equal(result.filteredTeam.canonicalId, "hle");
assert.equal(result.matches[0].team1.currentName, "Hanwha Life Esports");
assert.equal(result.matches[0].team2.currentName, "T1");
assert.deepEqual(result.matches[0].score, { team1: 1, team2: 0 });
});
test("normalizeStandingsResponse keeps the LCK standings shape and alias resolution", () => {
const table = normalizeStandingsResponse(standingsPayload, {
tournament: {
id: "tournament-2026",
slug: "lck-2026-spring",
name: "LCK 2026 Spring",
},
team: "T1",
});
assert.equal(table.tournamentId, "tournament-2026");
assert.equal(table.rows.length, 1);
assert.equal(table.rows[0].team.canonicalId, "t1");
assert.equal(table.rows[0].wins, 4);
assert.equal(table.rows[0].losses, 2);
});
test("public fetchers compose summary, standings, live details, and match analysis", async () => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url, options = {}) => {
const target = String(url);
calls.push({
target,
headers: options.headers || {},
});
if (target.includes("getSchedule")) {
return makeResponse(schedulePayload);
}
if (target.includes("getTournamentsForLeague")) {
return makeResponse(tournamentsPayload);
}
if (target.includes("getStandings")) {
return makeResponse(standingsPayload);
}
if (target.includes("getEventDetails")) {
return makeResponse(eventDetailsPayload);
}
if (target.includes("/window/game-1")) {
return makeResponse(liveWindowPayload);
}
if (target.includes("/details/game-1")) {
return makeResponse(liveDetailsPayload);
}
throw new Error(`unexpected url: ${target}`);
};
try {
const results = await getMatchResults("2026-04-01", { team: "한화" });
const standings = await getStandings({ date: "2026-04-01", team: "T1" });
const summary = await getLckSummary("2026-04-01", {
team: "한화",
includeStandings: true,
});
const analysis = await getMatchAnalysis("2026-04-01", {
team: "한화",
historicalDataset: buildHistoricalAnalytics(oracleCsv),
});
assert.equal(results.matches.length, 1);
assert.equal(results.matches[0].games[0].teams[0].team.currentName, "Hanwha Life Esports");
assert.equal(results.matches[0].live.killDiff, 5);
assert.equal(standings.rows[0].team.currentName, "T1");
assert.equal(summary.standings.rows[0].team.currentName, "Hanwha Life Esports");
assert.equal(analysis.matches[0].analyses[0].draft.overallEdge, "blue");
assert.equal(analysis.matches[0].powerPreview.teamA.teamId, "hle");
assert.ok(
calls.some((call) => call.headers["x-api-key"]),
"expected Riot API requests to include an x-api-key header",
);
} finally {
global.fetch = originalFetch;
}
});
test("historical analytics parse Oracle-style CSV and power rankings", () => {
const parsedRows = parseOracleCsv(oracleCsv);
const historical = buildHistoricalAnalytics(parsedRows);
assert.equal(parsedRows.length, 4);
assert.equal(historical.rows.length, 4);
assert.equal(historical.teamPowerRatings[0].teamId, "hle");
assert.equal(historical.teamPowerRatings[0].wins, 2);
assert.equal(historical.patchMeta[0].patch, "16.6.753.8272");
assert.equal(historical.matchupStats[0].champion, "Aatrox");
});
test("getGameAnalysis computes turning points and draft context from injected live payloads", async () => {
const historical = buildHistoricalAnalytics(oracleCsv);
const analysis = await getGameAnalysis("game-1", {
matchId: "match-1",
number: 1,
state: "inProgress",
historicalDataset: historical,
liveWindowPayload,
liveDetailsPayload,
});
assert.equal(analysis.gameId, "game-1");
assert.equal(analysis.patch, "16.6.753.8272");
assert.equal(analysis.current.goldDiff, 3600);
assert.ok(analysis.turningPoints.length >= 1);
assert.equal(analysis.turningPoints[0].favoredSide, "blue");
assert.equal(analysis.draft.roleMatchups[0].role, "top");
assert.equal(analysis.meta.topPicks[0].champion, "Aatrox");
});
function makeResponse(body) {
return new Response(JSON.stringify(body), {
status: 200,
headers: {
"content-type": "application/json",
},
});
}

View file

@ -179,10 +179,39 @@ test("repository docs advertise the used-car-price-search skill", () => {
assert.match(install, /--skill used-car-price-search/);
assert.match(
install,
/npm install -g @ohah\/hwpjs kbo-game kleague-results toss-securities k-lotto coupang-product-search used-car-price-search korean-law-mcp/,
/npm install -g @ohah\/hwpjs kbo-game kleague-results lck-analytics toss-securities k-lotto coupang-product-search used-car-price-search korean-law-mcp/,
);
});
test("repository docs advertise the lck-analytics skill and package", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "lck-analytics.md");
const skillPath = path.join(repoRoot, "lck-analytics", "SKILL.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/lck-analytics.md to exist");
assert.ok(fs.existsSync(skillPath), "expected lck-analytics/SKILL.md to exist");
assert.match(readme, /\| LCK 경기 분석 \|/);
assert.match(readme, /\[LCK 경기 분석 가이드\]\(docs\/features\/lck-analytics\.md\)/);
assert.match(install, /--skill lck-analytics/);
assert.match(install, /npm install -g .*lck-analytics/);
});
test("lck-analytics docs and skill credit the original author and reference repo", () => {
const skill = read(path.join("lck-analytics", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "lck-analytics.md"));
const packageReadme = read(path.join("packages", "lck-analytics", "README.md"));
const sources = read(path.join("docs", "sources.md"));
for (const doc of [skill, featureDoc, packageReadme]) {
assert.match(doc, /jerjangmin/);
assert.match(doc, /https:\/\/github\.com\/jerjangmin\/share\/tree\/main\/SKILL\/lck-analytics/);
assert.match(doc, /Riot|LoL Esports|Oracle(?:'s)? Elixir/i);
}
assert.match(sources, /https:\/\/github\.com\/jerjangmin\/share\/tree\/main\/SKILL\/lck-analytics/);
});
test("used-car-price-search docs document the provider survey and SK direct surface", () => {
const skill = read(path.join("used-car-price-search", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "used-car-price-search.md"));
@ -684,6 +713,7 @@ 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 kleague-results/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace lck-analytics/);
});
test("repository docs advertise the kleague-results skill across the documented surfaces", () => {