From 18dcd47c38764bd1e5b379aa46c9ae1e78ac135e Mon Sep 17 00:00:00 2001 From: thwilk <135527401+thwilk@users.noreply.github.com> Date: Fri, 27 Jun 2025 12:17:24 -0400 Subject: [PATCH 1/4] Testing commit --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 0832dd6..ccc1a06 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # NCAA API +Test + Free API to return consumable data from ncaa.com. Works with scores, stats, rankings, standings, schedules, history, and game details (box score, play by play, scoring summary, team stats). From 24aff8e9737d14a6c2d955ad7bb1fa8b3966dd4a Mon Sep 17 00:00:00 2001 From: thwilk <135527401+thwilk@users.noreply.github.com> Date: Fri, 27 Jun 2025 14:24:41 -0400 Subject: [PATCH 2/4] Added teams path which extracts a team's schedule --- src/index.ts | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index f032f24..fbcb028 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ const validRoutes = new Map([ ['schools-index', cache_30m], ['game', cache_1m], ['scoreboard', cache_1m], + ['teams', cache_1m] ]) /** log message to console with timestamp */ @@ -48,7 +49,8 @@ export const app = new Elysia() } // check that resource is valid const basePath = path.split('/')[1] - if (!validRoutes.has(basePath)) { + + if (!validRoutes.has(basePath) && basePath!='teams') { return error(400, 'Invalid resource') } return { @@ -80,6 +82,40 @@ export const app = new Elysia() return error(500, 'Error fetching data') } }) + // team's schedule + .get('/teams/:id', async ({ cache, cacheKey, error, params: { id } }) => { + if (!id) return error(400, 'Team id is required') + + const url = `https://stats.ncaa.org/teams/${id}` // gets team page + const req = await fetch(url) + if (!req.ok) return error(404, 'Team page not found') + + const html = await req.text() + const { document } = parseHTML(html) + + const table = document.querySelector('table') + if (!table) return error(500, 'Could not find schedule table') + + const rows = table.querySelectorAll('tbody tr.underline_rows') + + const schedule = Array.from(rows).map(row => { + const cells = row.querySelectorAll('td') // gets all rows + + const date = cells[0]?.textContent?.trim() ?? '' + const opponent = cells[1]?.querySelector('a')?.textContent?.trim() ?? '' + const result = cells[2]?.querySelector('a')?.textContent?.trim() ?? '' + const boxScoreHref = cells[2]?.querySelector('a')?.getAttribute('href') ?? '' + const box_score_url = boxScoreHref ? `https://stats.ncaa.org${boxScoreHref}` : '' + const attendance = cells[3]?.textContent?.trim() ?? '' + log("6") + return { date, opponent, result, box_score_url, attendance } + }) + + const result = JSON.stringify(schedule) + cache.set(cacheKey, result) + return result + }) + // game route to retrieve game details .get('/game/:id?/:page?', async ({ cache, cacheKey, error, params: { id, page } }) => { if (!id) { From d39cb54b361ba6fde5b91b7cbf9187035c5db9a2 Mon Sep 17 00:00:00 2001 From: thwilk <135527401+thwilk@users.noreply.github.com> Date: Fri, 4 Jul 2025 01:40:50 -0400 Subject: [PATCH 3/4] Created new endpoint which gets ID of a contest --- dockerfile | 65 ++++++++++++++---------- package.json | 4 +- src/index.ts | 141 +++++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 176 insertions(+), 34 deletions(-) diff --git a/dockerfile b/dockerfile index 206bdd3..8f4bfbd 100644 --- a/dockerfile +++ b/dockerfile @@ -1,28 +1,37 @@ -FROM oven/bun:slim AS builder - -WORKDIR /app - -ENV NODE_ENV=production - -COPY package.json bun.lockb tsconfig.json ./ -RUN bun install --production --no-cache - -COPY src src - -RUN bun build \ - --compile \ - --minify-whitespace \ - --minify-syntax \ - --target bun \ - --outfile server \ - ./src/index.ts - -# ? ------------------------- - -FROM gcr.io/distroless/base:nonroot - -COPY --from=builder /app/server . - -CMD ["./server"] - -EXPOSE 3000 +# -------------------- BUILD STAGE -------------------- + FROM oven/bun:slim AS builder + + WORKDIR /app + + ENV NODE_ENV=production + + # copy only the files needed for install + COPY package.json bun.lockb tsconfig.json ./ + + # install dependencies (skip frozen lockfile issues) + RUN rm -f bun.lockb && bun install --production --no-cache + + # copy source after install for better cache usage + COPY src ./src + + # build your app to a single binary + RUN bun build \ + --compile \ + --minify-whitespace \ + --minify-syntax \ + --target bun \ + --outfile server \ + ./src/index.ts + + # -------------------- RUNTIME STAGE -------------------- + FROM gcr.io/distroless/base:nonroot + + WORKDIR / + + # copy built binary from builder stage + COPY --from=builder /app/server . + + # expose port and define startup command + EXPOSE 3000 + CMD ["./server"] + \ No newline at end of file diff --git a/package.json b/package.json index 27b1e2f..561bb31 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,9 @@ }, "dependencies": { "@henrygd/semaphore": "^0.0.2", - "elysia": "^1.2.10", + "elysia": "^1.3.5", "expiry-map": "^2.0.0", - "linkedom": "^0.18.6" + "linkedom": "^0.18.11" }, "devDependencies": { "bun-types": "latest" diff --git a/src/index.ts b/src/index.ts index fbcb028..937c976 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,7 +19,7 @@ const validRoutes = new Map([ ['schools-index', cache_30m], ['game', cache_1m], ['scoreboard', cache_1m], - ['teams', cache_1m] + ['org', cache_30m] ]) /** log message to console with timestamp */ @@ -50,7 +50,7 @@ export const app = new Elysia() // check that resource is valid const basePath = path.split('/')[1] - if (!validRoutes.has(basePath) && basePath!='teams') { + if (!validRoutes.has(basePath)) { return error(400, 'Invalid resource') } return { @@ -82,8 +82,11 @@ export const app = new Elysia() return error(500, 'Error fetching data') } }) + + + // team's schedule - .get('/teams/:id', async ({ cache, cacheKey, error, params: { id } }) => { + .get('org/teams/:id', async ({ cache, cacheKey, error, params: { id } }) => { if (!id) return error(400, 'Team id is required') const url = `https://stats.ncaa.org/teams/${id}` // gets team page @@ -107,7 +110,6 @@ export const app = new Elysia() const boxScoreHref = cells[2]?.querySelector('a')?.getAttribute('href') ?? '' const box_score_url = boxScoreHref ? `https://stats.ncaa.org${boxScoreHref}` : '' const attendance = cells[3]?.textContent?.trim() ?? '' - log("6") return { date, opponent, result, box_score_url, attendance } }) @@ -115,6 +117,137 @@ export const app = new Elysia() cache.set(cacheKey, result) return result }) + + + + // PARARMS: + // home = home team name + // away = away team name + // date = date game takes place MM-DD-YYY format + // sport = wlax or mlax + // divsion = 1, 2, or 3 + .get('/org/gameid/:home/:away/:date/:sport/:division/', async ({ cache, cacheKey, error, params: { home, away, date, sport, division } }) => { + if (!away) return error(400, 'Away team name is required') + if (!home) return error(400, 'Home team name is required') + if (!sport) return error(400, 'Sport is required (mlax or wlax)') + if (!division) return error(400, 'Division is required') + if (!date) return error(400, 'Date is required') + + // parse date in MM-DD-YYYY format + const [monthStr, dayStr, yearStr] = date.split('-') + if (!monthStr || !dayStr || !yearStr) return error(400, 'Invalid date format') + + const inputDate = new Date(`${yearStr}-${monthStr}-${dayStr}`) + const now = new Date() + + if (isNaN(inputDate.getTime())) return error(400, 'Invalid date') + if (parseInt(yearStr) < 2024) return error(402, 'Date is too early') + if (inputDate > now) return error(403, "Date hasn't happened yet") + + // determine season label + let yearLabel: string + if (yearStr === '2025') { + yearLabel = '24-25' + } else if (yearStr === '2024') { + yearLabel = '23-24' + } else { + return error(400, 'Unsupported season year') + } + + // determine gender + let gender: string + if (sport === 'mlax') gender = 'M' + else if (sport === 'wlax') gender = 'F' + else return error(400, 'Sport must be mlax or wlax') + + // parse division + const div = parseInt(division) + if (![1, 2, 3].includes(div)) return error(400, 'Division must be 1, 2, or 3') + + // manually assign season_division_id + let seasonId: number | null = null + + if (yearLabel === '24-25') { + if (div === 1 && gender === 'M') seasonId = 18484 + else if (div === 2 && gender === 'M') seasonId = 18485 + else if (div === 3 && gender === 'M') seasonId = 18487 + else if (div === 1 && gender === 'F') seasonId = 18483 + else if (div === 2 && gender === 'F') seasonId = 18486 + else if (div === 3 && gender === 'F') seasonId = 18488 + } + + if (yearLabel === '23-24') { + if (div === 1 && gender === 'M') seasonId = 18240 + else if (div === 2 && gender === 'M') seasonId = 18241 + else if (div === 3 && gender === 'M') seasonId = 18242 + else if (div === 1 && gender === 'F') seasonId = 18260 + else if (div === 2 && gender === 'F') seasonId = 18262 + else if (div === 3 && gender === 'F') seasonId = 18263 + } + + if (!seasonId) return error(400, 'Could not find season division ID') + + + const url = `https://stats.ncaa.org/contests/livestream_scoreboards?utf8=✓&season_division_id=${seasonId}&game_date=${monthStr}%2F${dayStr}%2F${yearStr}conference_id=0&tournament_id=&commit=Submit` + + const res = await fetch(url) + if (!res.ok) return error(404, 'Could not fetch scoreboard page') + + const html = await res.text() + const { document } = parseHTML(html) + + // Find all tables + const tables = document.querySelectorAll('table') + + let foundHref: string | null = null + + for (const table of tables) { + const cells = Array.from(table.querySelectorAll('td')).map((td) => + td.textContent?.trim() + ) + + const team1Match = cells.some((text) => + text?.toLowerCase().includes(home.toLowerCase()) + ) + const team2Match = cells.some((text) => + text?.toLowerCase().includes(away.toLowerCase()) + ) + + if (team1Match && team2Match) { + const boxLink = Array.from(table.querySelectorAll('a')).find( + (a) => a.textContent?.trim() === 'Box Score' + ) + + if (boxLink) { + foundHref = boxLink.getAttribute('href') + break + } + } + } + + if (!foundHref) { + return error(404, `No game found between "${home}" and "${away}" on ${date}`) + } + + // Extract numeric game ID from URL + const idMatch = foundHref.match(/\/(\d+)\//) + const gameId = idMatch ? idMatch[1] : null + + if (!gameId) { + return error(500, 'Box score found but could not extract game ID') + } + + const data = { + game_id: gameId, + box_score_url: `https://stats.ncaa.org${foundHref}`, + fetched_from: url + } + + cache.set(cacheKey, data) + return data + }) + + // game route to retrieve game details .get('/game/:id?/:page?', async ({ cache, cacheKey, error, params: { id, page } }) => { From ded887994911b29476f8ff027fadce3bb6ac2201 Mon Sep 17 00:00:00 2001 From: thwilk <135527401+thwilk@users.noreply.github.com> Date: Fri, 4 Jul 2025 02:13:46 -0400 Subject: [PATCH 4/4] Added get playbyplay endpoint --- src/index.ts | 76 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 63 insertions(+), 13 deletions(-) diff --git a/src/index.ts b/src/index.ts index 937c976..8920c9d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,9 +82,65 @@ export const app = new Elysia() return error(500, 'Error fetching data') } }) + .get('/org/playbyplay/:game_id', async ({ cache, cacheKey, error, params: { game_id } }) => { + if (!game_id) return error(400, 'Game ID is required') + + const url = `https://stats.ncaa.org/contests/${game_id}/play_by_play` + + const res = await fetch(url) + if (!res.ok) return error(404, 'Game page not found') + + const html = await res.text() + const { document } = parseHTML(html) + + const quarterHeaders = Array.from(document.querySelectorAll('.card-header')) + const allTables = Array.from(document.querySelectorAll('table.table')) + + const stats = [] + const periodNumbers = [] + + for (let i = 0; i < quarterHeaders.length; i++) { + const header = quarterHeaders[i] + const table = allTables[i] + + if (!header || !table) continue + + // extract "1st Quarter", "2nd Quarter", etc. + const periodLabel = header.textContent?.trim() + const match = periodLabel?.match(/(\d+)/) + const period = match ? parseInt(match[1]) : i + 1 + + periodNumbers.push(period) + + const rows = Array.from(table.querySelectorAll('tbody tr')) + + for (const row of rows) { + const cells = Array.from(row.querySelectorAll('td')).map((td) => + td.textContent?.trim() + ) + + if (cells.length === 4) { + stats.push({ + period, + time: cells[0], + team1: cells[1], + score: cells[2], + team2: cells[3] + }) + } + } + } + + const data = + { + game: game_id, + stats + } + cache.set(cacheKey, data) - + return data + }) // team's schedule .get('org/teams/:id', async ({ cache, cacheKey, error, params: { id } }) => { if (!id) return error(400, 'Team id is required') @@ -117,20 +173,19 @@ export const app = new Elysia() cache.set(cacheKey, result) return result }) - - - // PARARMS: // home = home team name // away = away team name // date = date game takes place MM-DD-YYY format // sport = wlax or mlax // divsion = 1, 2, or 3 - .get('/org/gameid/:home/:away/:date/:sport/:division/', async ({ cache, cacheKey, error, params: { home, away, date, sport, division } }) => { + .get('/org/gameid/:home/:away/:date/:sport?/:division?', async ({ cache, cacheKey, params, error }) => { + const { home, away, date } = params + const sport = params.sport || 'mlax' + const division = params.division || '1' + if (!away) return error(400, 'Away team name is required') if (!home) return error(400, 'Home team name is required') - if (!sport) return error(400, 'Sport is required (mlax or wlax)') - if (!division) return error(400, 'Division is required') if (!date) return error(400, 'Date is required') // parse date in MM-DD-YYYY format @@ -238,17 +293,12 @@ export const app = new Elysia() } const data = { - game_id: gameId, - box_score_url: `https://stats.ncaa.org${foundHref}`, - fetched_from: url + game_id: gameId } cache.set(cacheKey, data) return data }) - - - // game route to retrieve game details .get('/game/:id?/:page?', async ({ cache, cacheKey, error, params: { id, page } }) => { if (!id) {