Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
teamStatsHashes,
} from "./codes";
import { openapiSpec } from "./openapi";
import { parseStatSelect } from "./stats/stat-category-parser";
import {
convertToOldFormat,
fetchGqlScoreboard,
Expand All @@ -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);

Expand Down Expand Up @@ -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)) {
Expand Down
29 changes: 28 additions & 1 deletion src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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: {},
Expand Down
55 changes: 55 additions & 0 deletions src/stats/stat-category-parser.ts
Original file line number Diff line number Diff line change
@@ -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 <select> element in an NCAA.com stat page.
*
* NCAA stat pages have two dropdowns:
* #select-container-individual — individual stat categories
* #select-container-team — team stat categories
*
* Each option's value is a relative path like /stats/soccer-men/d1/current/individual/421
*/
export function parseStatSelect(
document: Document,
selectId: string,
pathType: "individual" | "team",
): StatPathEntry[] {
const select = document.getElementById(selectId) as HTMLSelectElement | null;
if (!select) {
return [];
}

const entries: StatPathEntry[] = [];

for (const option of select.options) {
const value = option.value?.trim();
const name = option.textContent?.trim();

// Skip empty/placeholder options (e.g., "Select an Individual Statistic")
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 || !match[1]) {
continue;
}

entries.push({
id: match[1],
name: name,
path: `${pathType}/${match[1]}`,
});
}

return entries;
}
45 changes: 45 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,51 @@ 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/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", "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/basketball-men/d1")
);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.individual).toBeArray();
expect(data.individual.length).toBeGreaterThan(0);
});
it("returns 404 for unknown sport", async () => {
const response = await app.handle(
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/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/soccer-men/d1"));
const elapsed = performance.now() - start;
expect(elapsed).toBeLessThan(10);
});
});

describe("Header validation", () => {
// Custom header tests (must be last due to env var)
it("valid custom header returns 200", async () => {
Expand Down
91 changes: 91 additions & 0 deletions test/stat-category-parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, it } from "bun:test";
import { parseHTML } from "linkedom";
import { parseStatSelect } from "../src/stats/stat-category-parser";

describe("parseStatSelect", () => {
it("parses a standard NCAA stat dropdown", () => {
const html = `<html><body>
<select id="select-container-individual">
<option value="">Select an Individual Statistic</option>
<option value="/stats/soccer-men/d1/current/individual/5">Goals Per Game</option>
<option value="/stats/soccer-men/d1/current/individual/6">Assists Per Game</option>
</select>
</body></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" },
{ id: "6", name: "Assists Per Game", path: "individual/6" },
]);
});

it("returns empty array when select element is missing", () => {
const html = "<html><body><p>No dropdown here</p></body></html>";
const { document } = parseHTML(html);
const result = parseStatSelect(document, "select-container-individual", "individual");

expect(result).toEqual([]);
});

it("skips placeholder options", () => {
const html = `<html><body>
<select id="select-container-team">
<option value="">Select a Team Statistic</option>
<option value="/stats/soccer-men/d1/current/team/30">Scoring Offense</option>
</select>
</body></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 = `<html><body>
<select id="select-container-individual">
<option value="">Choose</option>
<option value="/stats/soccer-men/d1/current/individual/10">Saves Per Game</option>
</select>
</body></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 = `<html><body>
<select id="select-container-individual">
<option value="not-a-valid-url">Bad Option</option>
<option value="/stats/soccer-men/d1/current/individual/5">Goals Per Game</option>
<option value="/some/other/path">Another Bad One</option>
</select>
</body></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 = `<html><body>
<select id="select-container-individual"></select>
</body></html>`;

const { document } = parseHTML(html);
const result = parseStatSelect(document, "select-container-individual", "individual");

expect(result).toEqual([]);
});
});