k-skill/packages/kbl-results/src/parse.js
Jeffrey (Dongkyu) Kim 7c0bfa4c93
Feature/#129 (#131)
* Add official KBL results support so basketball queries use live league data

Issue #129 needs a read-only skill and reusable package for KBL schedules, results, and standings. The implementation follows the existing sports package pattern and uses the league's live JSON APIs after verifying they respond successfully in real requests.

Constraint: Must use official KBL JSON surfaces before considering scraping
Constraint: Packaging changes must pass npm run ci and include docs plus Changesets updates
Rejected: Browser scraping first | official api.kbl.or.kr endpoints are live and simpler to maintain
Rejected: Reuse KBO/K League package shapes verbatim | KBL payload and team/status fields differ materially
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep seasonGrade=1 as the default KBL path unless future docs/tests explicitly widen to D-League flows
Tested: npm run ci; npm run lint --workspace kbl-results; npm test --workspace kbl-results; live getKBLSummary("2026-04-01", { team: "KCC", includeStandings: true })
Not-tested: Historical standings snapshots for past seasons via alternative KBL endpoints

* Prevent optional standings lookups from over-fetching the KBL API

The new kbl-results summary helper exposes includeStandings=false, so the
regression suite now proves that path stays schedule-only and never calls
the standings endpoint when the caller opts out.

Constraint: The KBL package should preserve the caller's no-standings contract
Rejected: Rely on manual inspection of the helper options | a targeted test is cheaper and safer
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep includeStandings=false side-effect free unless the public API contract changes explicitly
Tested: npm test --workspace kbl-results; npm run lint --workspace kbl-results
Not-tested: Full-repo CI before stacking this commit onto the rebased branch
2026-04-18 11:20:42 +09:00

436 lines
13 KiB
JavaScript

const KNOWN_TEAMS = [
{ code: "50", name: "창원 LG", fullName: "창원 LG 세이커스", logoClass: "lg", aliases: ["LG", "세이커스"] },
{ code: "70", name: "안양 정관장", fullName: "안양 정관장 레드부스터스", logoClass: "kgc", aliases: ["정관장", "KGC", "레드부스터스"] },
{ code: "16", name: "원주 DB", fullName: "원주 DB 프로미", logoClass: "db", aliases: ["DB", "프로미"] },
{ code: "55", name: "서울 SK", fullName: "서울 SK 나이츠", logoClass: "sk", aliases: ["SK", "나이츠"] },
{ code: "66", name: "고양 소노", fullName: "고양 소노 스카이거너스", logoClass: "sono", aliases: ["소노", "스카이거너스", "SONO"] },
{ code: "60", name: "부산 KCC", fullName: "부산 KCC 이지스", logoClass: "kcc", aliases: ["KCC", "이지스"] },
{ code: "06", name: "수원 KT", fullName: "수원 KT 소닉붐", logoClass: "kt", aliases: ["KT", "소닉붐"] },
{ code: "10", name: "울산 현대모비스", fullName: "울산 현대모비스 피버스", logoClass: "hd", aliases: ["현대모비스", "모비스", "피버스"] },
{ code: "64", name: "대구 한국가스공사", fullName: "대구 한국가스공사 페가수스", logoClass: "pega", aliases: ["한국가스공사", "가스공사", "페가수스"] },
{ code: "35", name: "서울 삼성", fullName: "서울 삼성 썬더스", logoClass: "ss", aliases: ["삼성", "썬더스"] },
];
const STATUS_MAP = {
live: { code: "LIVE", state: "live", label: "진행 중" },
finished: { code: "ENDED", state: "finished", label: "종료" },
scheduled: { code: "SCHEDULED", state: "scheduled", label: "예정" },
};
function normalizeDateInput(value) {
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) {
throw new Error("date must be a valid Date or YYYY-MM-DD string.");
}
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Seoul",
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const parts = formatter.formatToParts(value).reduce((acc, part) => {
if (part.type !== "literal") {
acc[part.type] = part.value;
}
return acc;
}, {});
return buildDateParts(parts.year, parts.month, parts.day);
}
const match = String(value || "").trim().match(/^(\d{4})[-.](\d{2})[-.](\d{2})$/);
if (!match || !isValidCalendarDate(match[1], match[2], match[3])) {
throw new Error("date must be a valid Date or YYYY-MM-DD string.");
}
return buildDateParts(match[1], match[2], match[3]);
}
function normalizeScheduleResponse(payload, options = {}) {
const queryDate = options.date ? normalizeDateInput(options.date) : null;
const seasonGrade = options.seasonGrade == null ? 1 : Number(options.seasonGrade);
const teamDirectory = buildTeamDirectory({
scheduleRows: payload,
standingsRows: options.standingsRows,
});
const requestedTeam = options.team ? resolveTeamQuery(options.team, teamDirectory) : null;
const rows = Array.isArray(payload) ? payload : [];
const matches = rows
.filter((item) => Number(item.seasonGrade || 1) === seasonGrade)
.filter((item) => !queryDate || item.gameDate === queryDate.compactDate)
.filter((item) => !requestedTeam || itemMatchesRequestedTeam(item, requestedTeam))
.map((item) => normalizeScheduleItem(item, teamDirectory))
.sort(compareMatches);
return {
queryDate: queryDate?.isoDate ?? null,
seasonGrade,
filteredTeam: requestedTeam
? {
input: requestedTeam.input,
normalized: requestedTeam.fullName,
code: requestedTeam.code,
}
: null,
matches,
};
}
function normalizeStandingsResponse(payload) {
const teamDirectory = buildTeamDirectory({ standingsRows: payload });
const rows = (Array.isArray(payload) ? payload : [])
.map((item) => {
const team = stripAliasTokens(getTeam(item.tcode, item.tname, item.tnameF, item.teamLogoClass, teamDirectory));
const win = normalizeNumber(item.win) ?? 0;
const loss = normalizeNumber(item.loss) ?? 0;
const draw = normalizeNumber(item.draw) ?? 0;
return {
rank: normalizeNumber(item.rank),
team,
win,
loss,
draw,
gamesBehind: normalizeNumber(item.winDiff) ?? 0,
winningPercentage: calculateWinningPercentage(win, loss, draw),
home: {
win: normalizeNumber(item.hwin) ?? 0,
loss: normalizeNumber(item.hloss) ?? 0,
},
away: {
win: normalizeNumber(item.awin) ?? 0,
loss: normalizeNumber(item.aloss) ?? 0,
},
streak: {
win: normalizeNumber(item.contiWin) ?? 0,
loss: normalizeNumber(item.contiLoss) ?? 0,
},
maxStreak: {
win: normalizeNumber(item.maxWin) ?? 0,
loss: normalizeNumber(item.maxLoss) ?? 0,
},
lastFive: normalizeLastRecord(item.lastRecord),
};
})
.sort((left, right) => left.rank - right.rank);
return {
rows,
};
}
function normalizeScheduleItem(item, teamDirectory) {
const homeTeam = stripAliasTokens(getTeam(item.tcodeH, item.tnameH, item.tnameFH, item.logoH, teamDirectory));
const awayTeam = stripAliasTokens(getTeam(item.tcodeA, item.tnameA, item.tnameFA, item.logoA, teamDirectory));
const status = normalizeMatchStatus(item);
const score = {
home: normalizeNumber(item.scoreH),
away: normalizeNumber(item.scoreA),
};
return {
gameKey: item.gmkey || null,
gameNumber: normalizeNumber(item.gameNo),
gameCode: item.gameCode || null,
seasonCode: normalizeNumber(item.seasonCode),
seasonGrade: normalizeNumber(item.seasonGrade),
competitionName: item.seasonName1 || null,
seasonCategory: {
code: item.seasonCategory || null,
label: item.seasonCategoryName || null,
},
date: compactDateToIso(item.gameDate),
dateLabel: item.gameDate || null,
weekDay: item.weekDay || null,
startTime: compactTimeToClock(item.gameStart),
endTime: compactTimeToClock(item.gameEnd),
status,
homeTeam,
awayTeam,
score,
winner: determineWinner(score, status, homeTeam, awayTeam),
venue: {
shortName: item.stadiumname || null,
name: item.stadiumnameF || item.stadiumname || null,
},
broadcastChannels: splitBroadcastChannels(item.tv),
};
}
function buildTeamDirectory({ scheduleRows = [], standingsRows = [] } = {}) {
const directory = new Map();
for (const team of KNOWN_TEAMS) {
upsertTeam(directory, team.code, team.name, team.fullName, team.logoClass, team.aliases);
}
for (const item of scheduleRows || []) {
upsertTeam(directory, item.tcodeH, item.tnameH, item.tnameFH, item.logoH);
upsertTeam(directory, item.tcodeA, item.tnameA, item.tnameFA, item.logoA);
}
for (const item of standingsRows || []) {
upsertTeam(directory, item.tcode, item.tname, item.tnameF, item.teamLogoClass);
}
return directory;
}
function upsertTeam(directory, code, name, fullName, logoClass, aliases = []) {
if (!code) {
return;
}
const existing = directory.get(code) || {
code,
name: name || code,
fullName: fullName || name || code,
logoClass: logoClass || null,
aliasTokens: new Set(),
};
if (name) {
existing.name = name;
}
if (fullName) {
existing.fullName = fullName;
}
if (logoClass) {
existing.logoClass = logoClass;
}
const values = [
code,
name,
fullName,
logoClass,
...aliases,
removeCityPrefix(name),
removeCityPrefix(fullName),
extractEnglishFragment(name),
extractEnglishFragment(fullName),
].filter(Boolean);
for (const value of values) {
existing.aliasTokens.add(normalizeToken(value));
}
directory.set(code, existing);
}
function resolveTeamQuery(query, teamDirectory) {
const input = String(query || "").trim();
const token = normalizeToken(input);
const exact = [];
const fuzzy = [];
for (const team of teamDirectory.values()) {
if (team.aliasTokens.has(token)) {
exact.push(team);
continue;
}
for (const alias of team.aliasTokens) {
if (alias.includes(token) || token.includes(alias)) {
fuzzy.push(team);
break;
}
}
}
const matches = exact.length ? exact : fuzzy;
if (matches.length === 1) {
return {
...matches[0],
input,
};
}
return {
code: null,
name: input,
fullName: input,
input,
token,
};
}
function itemMatchesRequestedTeam(item, requestedTeam) {
if (requestedTeam.code) {
return item.tcodeH === requestedTeam.code || item.tcodeA === requestedTeam.code;
}
return [
normalizeToken(item.tnameH),
normalizeToken(item.tnameFH),
normalizeToken(item.tnameA),
normalizeToken(item.tnameFA),
].some((token) => token && (token.includes(requestedTeam.token) || requestedTeam.token.includes(token)));
}
function normalizeMatchStatus(item) {
if (Number(item.isEnded) === 1) {
return {
...STATUS_MAP.finished,
finished: true,
};
}
if (Number(item.isStarted) === 1) {
return {
...STATUS_MAP.live,
finished: false,
quarter: item.playingQuarter || null,
};
}
return {
...STATUS_MAP.scheduled,
finished: false,
};
}
function determineWinner(score, status, homeTeam, awayTeam) {
if (!status.finished) {
return null;
}
if (score.home === score.away) {
return null;
}
return {
team: score.home > score.away ? homeTeam : awayTeam,
};
}
function getTeam(code, name, fullName, logoClass, teamDirectory) {
const team = teamDirectory.get(String(code)) || {};
return {
code: String(code),
name: name || team.name || String(code),
fullName: fullName || team.fullName || name || String(code),
logoClass: logoClass || team.logoClass || null,
aliasTokens: team.aliasTokens || new Set(),
};
}
function stripAliasTokens(team) {
const { aliasTokens, ...rest } = team;
return rest;
}
function normalizeToken(value) {
return String(value || "")
.normalize("NFKC")
.toUpperCase()
.replace(/[^0-9A-Z가-힣]+/g, "");
}
function buildDateParts(year, month, day) {
const paddedMonth = String(month).padStart(2, "0");
const paddedDay = String(day).padStart(2, "0");
return {
isoDate: `${year}-${paddedMonth}-${paddedDay}`,
compactDate: `${year}${paddedMonth}${paddedDay}`,
year: String(year),
month: paddedMonth,
day: paddedDay,
};
}
function isValidCalendarDate(year, month, day) {
const numericYear = Number(year);
const numericMonth = Number(month);
const numericDay = Number(day);
if (!Number.isInteger(numericYear) || !Number.isInteger(numericMonth) || !Number.isInteger(numericDay)) {
return false;
}
if (numericMonth < 1 || numericMonth > 12 || numericDay < 1) {
return false;
}
const maxDay = [31, isLeapYear(numericYear) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][numericMonth - 1];
return numericDay <= maxDay;
}
function isLeapYear(year) {
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
}
function normalizeNumber(value) {
if (value === null || value === undefined || value === "") {
return null;
}
const normalized = Number(value);
return Number.isFinite(normalized) ? normalized : null;
}
function compactDateToIso(value) {
const input = String(value || "");
if (!/^\d{8}$/.test(input)) {
return null;
}
return `${input.slice(0, 4)}-${input.slice(4, 6)}-${input.slice(6, 8)}`;
}
function compactTimeToClock(value) {
const input = String(value || "");
if (!/^\d{4}$/.test(input)) {
return null;
}
return `${input.slice(0, 2)}:${input.slice(2, 4)}`;
}
function splitBroadcastChannels(value) {
return String(value || "")
.split("/")
.map((entry) => entry.trim())
.filter(Boolean);
}
function compareMatches(left, right) {
const leftSortKey = `${left.date || ""}${(left.startTime || "").replace(":", "")}${String(left.gameNumber || "").padStart(4, "0")}`;
const rightSortKey = `${right.date || ""}${(right.startTime || "").replace(":", "")}${String(right.gameNumber || "").padStart(4, "0")}`;
return leftSortKey.localeCompare(rightSortKey);
}
function removeCityPrefix(value) {
const parts = String(value || "").trim().split(/\s+/);
return parts.length >= 2 ? parts.slice(1).join(" ") : value;
}
function extractEnglishFragment(value) {
const matches = String(value || "").match(/[A-Za-z]{2,}/g);
return matches ? matches.join(" ") : null;
}
function calculateWinningPercentage(win, loss, draw) {
const total = win + loss + draw;
if (!total) {
return 0;
}
return Number((win / total).toFixed(3));
}
function normalizeLastRecord(value) {
return Array.isArray(value)
? value.slice(0, 5).map((entry) => (Number(entry) === 1 ? "W" : "L"))
: [];
}
module.exports = {
buildTeamDirectory,
normalizeDateInput,
normalizeScheduleResponse,
normalizeStandingsResponse,
normalizeToken,
resolveTeamQuery,
};