diff --git a/src/index.ts b/src/index.ts index bccdcb0..f35f936 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import { teamStatsHashes, } from "./codes"; import { openapiSpec } from "./openapi"; +import { parseStatSelect } from "./stats/stat-category-parser"; import { convertToOldFormat, fetchGqlScoreboard, @@ -26,6 +27,9 @@ const cache_30m = new ExpiryMap(30 * 60 * 1000); // 45 second cache for scores const cache_45s = new ExpiryMap(1 * 45 * 1000); +// 24 hour cache for stat metadata (stat paths rarely change) +const cache_24h = new ExpiryMap(24 * 60 * 60 * 1000); + // 5 minute cache for brackets // const cache_5m = new ExpiryMap(5 * 60 * 1000); @@ -591,6 +595,31 @@ export const app = new Elysia() }, { detail: { hide: true } } ) + // 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 not found for this sport/division"); + } + const { document } = parseHTML(await res.text()); + const individual = parseStatSelect(document, "select-container-individual", "individual"); + const team = parseStatSelect(document, "select-container-team", "team"); + if (individual.length === 0 && team.length === 0) { + return status(404, "No stat categories found for this sport/division"); + } + const sport = document.querySelector("h2.page-title")?.textContent?.trim() ?? params.sport; + const data = JSON.stringify({ sport, individual, team }); + cache.set(cacheKey, data); + return data; + }, + { detail: { hide: true } } + ) // all other routes fetch data by scraping ncaa.com .get("/*", async ({ query: { page }, path, cache, cacheKey }) => { if (cache.has(cacheKey)) { diff --git a/src/openapi.ts b/src/openapi.ts index a364f7b..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,17 +183,44 @@ 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", in: "path", schema: { type: "string" }, required: true, + description: + "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/{sport}/{division}": { + get: { + responses: {}, + summary: "Stat categories", + description: + "Returns a list of available stat categories (individual and team) for a given sport and division. Add `year` and `path` to fetch specific stats.", + 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/stat-category-parser.ts b/src/stats/stat-category-parser.ts new file mode 100644 index 0000000..48e3fd4 --- /dev/null +++ b/src/stats/stat-category-parser.ts @@ -0,0 +1,55 @@ +type StatPathEntry = { + /** Numeric ID from the NCAA.com URL (e.g., "421") */ + id: string; + /** Human-readable stat name (e.g., "Goals") */ + name: string; + /** Full path for use with the /stats endpoint (e.g., "individual/421") */ + path: string; +}; + +/** + * Extract stat path entries from a + + + + + `; + + const { document } = parseHTML(html); + const result = parseStatSelect(document, "select-container-individual", "individual"); + + expect(result).toEqual([ + { id: "5", name: "Goals Per Game", path: "individual/5" }, + { id: "6", name: "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", name: "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", name: "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", name: "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([]); + }); +});