From 3d0729aacb69a1f45e5523d2851fe6d74c604e4f Mon Sep 17 00:00:00 2001 From: Kyle Kolodziej Date: Tue, 14 Apr 2026 16:47:42 -0700 Subject: [PATCH 1/2] add /stats-info/{sport}/{division} endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returns the list of available stat categories (individual + team) for a given sport and division, so API consumers can discover valid {path} values for the existing /stats endpoint without having to click around on ncaa.com. Response shape: { "individual": [{ "id": "5", "label": "Goals Per Game", "path": "individual/5" }, ...], "team": [{ "id": "30", "label": "Scoring Offense", "path": "team/30" }, ...] } Implementation notes: - Fetches https://www.ncaa.com/stats/{sport}/{division} on cache miss, parses the two ` dropdowns that NCAA.com renders on each stats landing page, so any sport/division combo that NCAA.com supports will work.\n\nhttps://www.ncaa.com/stats/soccer-men/d1", + parameters: [ + { + name: "sport", + in: "path", + schema: { type: "string" }, + required: true, + examples: sportExamples, + }, + { + name: "division", + in: "path", + schema: { type: "string" }, + required: true, + examples: divisionExamples, + }, + ] as OpenAPIV3.ParameterObject[], + }, + }, "/standings/{sport}/{path}": { get: { responses: {}, diff --git a/src/stats/parser.ts b/src/stats/parser.ts new file mode 100644 index 0000000..e337eb7 --- /dev/null +++ b/src/stats/parser.ts @@ -0,0 +1,48 @@ +import type { StatPathEntry } from "../types/stats"; + +/** + * Extract stat path entries from a + + + + + `; + + const { document } = parseHTML(html); + const result = parseStatSelect(document, "select-container-individual", "individual"); + + expect(result).toEqual([ + { id: "5", label: "Goals Per Game", path: "individual/5" }, + { id: "6", label: "Assists Per Game", path: "individual/6" }, + ]); + }); + + it("returns empty array when select element is missing", () => { + const html = "

No dropdown here

"; + const { document } = parseHTML(html); + const result = parseStatSelect(document, "select-container-individual", "individual"); + + expect(result).toEqual([]); + }); + + it("skips placeholder options", () => { + const html = ` + + `; + + const { document } = parseHTML(html); + const result = parseStatSelect(document, "select-container-team", "team"); + + expect(result).toEqual([ + { id: "30", label: "Scoring Offense", path: "team/30" }, + ]); + }); + + it("skips options with empty value", () => { + const html = ` + + `; + + const { document } = parseHTML(html); + const result = parseStatSelect(document, "select-container-individual", "individual"); + + expect(result).toEqual([ + { id: "10", label: "Saves Per Game", path: "individual/10" }, + ]); + }); + + it("skips options with malformed URLs", () => { + const html = ` + + `; + + const { document } = parseHTML(html); + const result = parseStatSelect(document, "select-container-individual", "individual"); + + expect(result).toEqual([ + { id: "5", label: "Goals Per Game", path: "individual/5" }, + ]); + }); + + it("handles empty dropdown (no options)", () => { + const html = ` + + `; + + const { document } = parseHTML(html); + const result = parseStatSelect(document, "select-container-individual", "individual"); + + expect(result).toEqual([]); + }); +}); From 84a1bad1f6074eb5e824ea8775d36e01877af36b Mon Sep 17 00:00:00 2001 From: henrygd Date: Thu, 16 Apr 2026 15:32:35 -0400 Subject: [PATCH 2/2] refactoring / route update --- src/index.ts | 17 +++++++------- src/openapi.ts | 11 +++++---- .../{parser.ts => stat-category-parser.ts} | 23 ++++++++++++------- src/types/stats.ts | 16 ------------- test/index.test.ts | 15 ++++++------ ...r.test.ts => stat-category-parser.test.ts} | 12 +++++----- 6 files changed, 44 insertions(+), 50 deletions(-) rename src/stats/{parser.ts => stat-category-parser.ts} (65%) delete mode 100644 src/types/stats.ts rename test/{parser.test.ts => stat-category-parser.test.ts} (87%) diff --git a/src/index.ts b/src/index.ts index 7ce3f2d..f35f936 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,7 @@ import { teamStatsHashes, } from "./codes"; import { openapiSpec } from "./openapi"; -import { parseStatSelect } from "./stats/parser"; +import { parseStatSelect } from "./stats/stat-category-parser"; import { convertToOldFormat, fetchGqlScoreboard, @@ -36,7 +36,6 @@ const cache_24h = new ExpiryMap(24 * 60 * 60 * 1000); // valid routes for the app with their respective caches const validRoutes = new Map([ ["stats", cache_30m], - ["stats-info", cache_24h], ["rankings", cache_30m], ["standings", cache_30m], ["history", cache_30m], @@ -116,8 +115,7 @@ export const app = new Elysia() }) .onBeforeHandle(({ set, cache, cacheKey }) => { set.headers["Content-Type"] = "application/json"; - const maxAge = cache === cache_45s ? 60 : cache === cache_24h ? 86400 : 1800; - set.headers["Cache-Control"] = `public, max-age=${maxAge}`; + set.headers["Cache-Control"] = `public, max-age=${cache === cache_45s ? 60 : 1800}`; if (cache.has(cacheKey)) { return cache.get(cacheKey); } @@ -597,15 +595,17 @@ export const app = new Elysia() }, { detail: { hide: true } } ) - // stats-info route to return available stat paths for a sport/division - .get("/stats-info/:sport/:division", async ({ params, cache, cacheKey, status }) => { + // stats route to return available stat categories for a sport/division + .get("/stats/:sport/:division", async ({ params, cacheKey, status, set }) => { + const cache = cache_24h; + set.headers["Cache-Control"] = `public, max-age=86400`; if (cache.has(cacheKey)) { return cache.get(cacheKey); } const url = `https://www.ncaa.com/stats/${params.sport}/${params.division}`; const res = await fetch(url); if (!res.ok) { - return status(404, "Stats info not found for this sport/division"); + return status(404, "Stats not found for this sport/division"); } const { document } = parseHTML(await res.text()); const individual = parseStatSelect(document, "select-container-individual", "individual"); @@ -613,7 +613,8 @@ export const app = new Elysia() if (individual.length === 0 && team.length === 0) { return status(404, "No stat categories found for this sport/division"); } - const data = JSON.stringify({ individual, team }); + const sport = document.querySelector("h2.page-title")?.textContent?.trim() ?? params.sport; + const data = JSON.stringify({ sport, individual, team }); cache.set(cacheKey, data); return data; }, diff --git a/src/openapi.ts b/src/openapi.ts index ce4b1ce..e75aeec 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -161,7 +161,7 @@ export const openapiSpec = openapi({ responses: {}, summary: "Stats", description: - "Stats for a given sport and division.\n\nhttps://www.ncaa.com/stats/football/fbs/current/individual/20\n\nhttps://www.ncaa.com/stats/football/fbs/2024/team/28", + "Stats for a given sport, division, and category.\n\nhttps://www.ncaa.com/stats/football/fbs/current/individual/20\n\nhttps://www.ncaa.com/stats/football/fbs/2024/team/28", parameters: [ { name: "sport", @@ -183,6 +183,7 @@ export const openapiSpec = openapi({ schema: { type: "string" }, required: true, examples: makeExamples(["current"]), + description: "Use `current` for the current season, or specify a year (e.g., `2024`).", }, { name: "path", @@ -190,18 +191,18 @@ export const openapiSpec = openapi({ schema: { type: "string" }, required: true, description: - "Stat category path (e.g., `individual/5` for Goals Per Game). Use the `/stats-info/{sport}/{division}` endpoint to discover available paths.", + "Stat category path (e.g., `individual/5`). Omit `year` and `path` to return a list of available stats.", examples: makeExamples(["individual/20", "team/28"]), }, ] as OpenAPIV3.ParameterObject[], }, }, - "/stats-info/{sport}/{division}": { + "/stats/{sport}/{division}": { get: { responses: {}, - summary: "Available stat paths", + summary: "Stat categories", description: - "Returns available stat categories (individual and team) for a given sport and division. Use the returned `path` values with the `/stats` endpoint.\n\nBacked by the same ` element in an NCAA.com stat page. @@ -23,24 +30,24 @@ export function parseStatSelect( for (const option of select.options) { const value = option.value?.trim(); - const label = option.textContent?.trim(); + const name = option.textContent?.trim(); // Skip empty/placeholder options (e.g., "Select an Individual Statistic") - if (!value || !label || /^select\b/i.test(label)) { + if (!value || !name || /^select\b/i.test(name)) { continue; } // Extract the numeric ID from the URL // e.g., /stats/soccer-men/d1/current/individual/421 - const match = value.match(/\/current\/(individual|team)\/(\d+)/); - if (!match) { + const match = value.match(/\/current\/(?:individual|team)\/(\d+)/); + if (!match || !match[1]) { continue; } entries.push({ - id: match[2], - label, - path: `${pathType}/${match[2]}`, + id: match[1], + name: name, + path: `${pathType}/${match[1]}`, }); } diff --git a/src/types/stats.ts b/src/types/stats.ts deleted file mode 100644 index 2fa8391..0000000 --- a/src/types/stats.ts +++ /dev/null @@ -1,16 +0,0 @@ -export type StatPathEntry = { - /** Numeric ID from the NCAA.com URL (e.g., "421") */ - id: string; - /** Human-readable stat name (e.g., "Goals") */ - label: string; - /** Full path for use with the /stats endpoint (e.g., "individual/421") */ - path: string; -}; - -export type StatPathsByType = { - individual: StatPathEntry[]; - team: StatPathEntry[]; -}; - -/** Keyed by sport (e.g., "soccer-men") → division (e.g., "d1") */ -export type StatPathsData = Record>; diff --git a/test/index.test.ts b/test/index.test.ts index 0f2ccd8..9d932b3 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -196,21 +196,22 @@ describe("General", () => { describe("Stats info", () => { it("soccer-men/d1 returns individual and team stat paths", async () => { const response = await app.handle( - new Request("http://localhost/stats-info/soccer-men/d1") + new Request("http://localhost/stats/soccer-men/d1") ); expect(response.status).toBe(200); expect(response.headers.get("cache-control")).toBe("public, max-age=86400"); const data = await response.json(); + expect(data).toContainKeys(["sport", "individual", "team"]); expect(data.individual).toBeArray(); expect(data.team).toBeArray(); expect(data.individual.length).toBeGreaterThan(0); expect(data.team.length).toBeGreaterThan(0); - expect(data.individual[0]).toContainKeys(["id", "label", "path"]); - expect(data.team[0]).toContainKeys(["id", "label", "path"]); + expect(data.individual[0]).toContainKeys(["id", "name", "path"]); + expect(data.team[0]).toContainKeys(["id", "name", "path"]); }); it("works for non-soccer sports (basketball-men/d1)", async () => { const response = await app.handle( - new Request("http://localhost/stats-info/basketball-men/d1") + new Request("http://localhost/stats/basketball-men/d1") ); expect(response.status).toBe(200); const data = await response.json(); @@ -219,19 +220,19 @@ describe("Stats info", () => { }); it("returns 404 for unknown sport", async () => { const response = await app.handle( - new Request("http://localhost/stats-info/curling/d1") + new Request("http://localhost/stats/curling/d1") ); expect(response.status).toBe(404); }); it("returns 404 for unknown division", async () => { const response = await app.handle( - new Request("http://localhost/stats-info/soccer-men/d99") + new Request("http://localhost/stats/soccer-men/d99") ); expect(response.status).toBe(404); }); it("cache hit on repeat request", async () => { const start = performance.now(); - await app.handle(new Request("http://localhost/stats-info/soccer-men/d1")); + await app.handle(new Request("http://localhost/stats/soccer-men/d1")); const elapsed = performance.now() - start; expect(elapsed).toBeLessThan(10); }); diff --git a/test/parser.test.ts b/test/stat-category-parser.test.ts similarity index 87% rename from test/parser.test.ts rename to test/stat-category-parser.test.ts index ec13095..91c29a6 100644 --- a/test/parser.test.ts +++ b/test/stat-category-parser.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import { parseHTML } from "linkedom"; -import { parseStatSelect } from "../src/stats/parser"; +import { parseStatSelect } from "../src/stats/stat-category-parser"; describe("parseStatSelect", () => { it("parses a standard NCAA stat dropdown", () => { @@ -16,8 +16,8 @@ describe("parseStatSelect", () => { const result = parseStatSelect(document, "select-container-individual", "individual"); expect(result).toEqual([ - { id: "5", label: "Goals Per Game", path: "individual/5" }, - { id: "6", label: "Assists Per Game", path: "individual/6" }, + { id: "5", name: "Goals Per Game", path: "individual/5" }, + { id: "6", name: "Assists Per Game", path: "individual/6" }, ]); }); @@ -41,7 +41,7 @@ describe("parseStatSelect", () => { const result = parseStatSelect(document, "select-container-team", "team"); expect(result).toEqual([ - { id: "30", label: "Scoring Offense", path: "team/30" }, + { id: "30", name: "Scoring Offense", path: "team/30" }, ]); }); @@ -57,7 +57,7 @@ describe("parseStatSelect", () => { const result = parseStatSelect(document, "select-container-individual", "individual"); expect(result).toEqual([ - { id: "10", label: "Saves Per Game", path: "individual/10" }, + { id: "10", name: "Saves Per Game", path: "individual/10" }, ]); }); @@ -74,7 +74,7 @@ describe("parseStatSelect", () => { const result = parseStatSelect(document, "select-container-individual", "individual"); expect(result).toEqual([ - { id: "5", label: "Goals Per Game", path: "individual/5" }, + { id: "5", name: "Goals Per Game", path: "individual/5" }, ]); });