308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
|
|
/**
|
|||
|
|
* Wikipedia tennis Grand Slam draw adapter.
|
|||
|
|
*
|
|||
|
|
* Source of truth: the singles-draw article (e.g.
|
|||
|
|
* "2025 Wimbledon Championships – Men's singles"), whose full bracket is encoded
|
|||
|
|
* as MediaWiki bracket templates:
|
|||
|
|
* - eight `{{16TeamBracket-Compact-Tennis5}}` sections (Round of 128 → Round
|
|||
|
|
* of 16), 16 players each = 128, and
|
|||
|
|
* - one `{{8TeamBracket-Tennis5}}` finals bracket (Quarterfinals → Final).
|
|||
|
|
* (`-Tennis3` best-of-3 variants render women's draws identically for our needs.)
|
|||
|
|
*
|
|||
|
|
* The winner of each match is the slot whose `team` value is bolded (`'''…'''`)
|
|||
|
|
* — losers are never bolded — which lets us derive winner/loser without parsing
|
|||
|
|
* set scores. Player names come from `[[wikilinks]]`, whose target is a stable
|
|||
|
|
* cross-year id used to match `seasonParticipants.externalId`.
|
|||
|
|
*
|
|||
|
|
* Parsing is split out (pure functions) so it can be unit-tested against a saved
|
|||
|
|
* wikitext fixture without network access.
|
|||
|
|
*/
|
|||
|
|
import type {
|
|||
|
|
DrawSyncAdapter,
|
|||
|
|
FetchedDraw,
|
|||
|
|
FetchedDrawMatch,
|
|||
|
|
FetchedPlayer,
|
|||
|
|
FetchedRound,
|
|||
|
|
} from "./types";
|
|||
|
|
|
|||
|
|
const WIKIPEDIA_API = "https://en.wikipedia.org/w/api.php";
|
|||
|
|
const CONTACT = process.env.WIKIPEDIA_CONTACT_EMAIL ?? "admin@brackt.com";
|
|||
|
|
const USER_AGENT = `Brackt.com tennis draw sync - ${CONTACT}`;
|
|||
|
|
|
|||
|
|
/** Round names for a 16-player draw section, earliest → latest. */
|
|||
|
|
const SECTION_ROUNDS = ["Round of 128", "Round of 64", "Round of 32", "Round of 16"] as const;
|
|||
|
|
/** Round names for the 8-player finals bracket, earliest → latest. */
|
|||
|
|
const FINALS_ROUNDS = ["Quarterfinals", "Semifinals", "Final"] as const;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Accept either a plain article title or a pasted Wikipedia URL and return the
|
|||
|
|
* article title. URLs like
|
|||
|
|
* `https://en.wikipedia.org/wiki/2025_Wimbledon_Championships_%E2%80%93_Men%27s_singles`
|
|||
|
|
* become "2025 Wimbledon Championships – Men's singles".
|
|||
|
|
*/
|
|||
|
|
export function articleTitleFromInput(input: string): string {
|
|||
|
|
const trimmed = input.trim();
|
|||
|
|
const match = trimmed.match(/\/wiki\/([^?#]+)/);
|
|||
|
|
if (!match) return trimmed;
|
|||
|
|
let title = match[1];
|
|||
|
|
try {
|
|||
|
|
title = decodeURIComponent(title);
|
|||
|
|
} catch {
|
|||
|
|
// leave as-is if it isn't valid percent-encoding
|
|||
|
|
}
|
|||
|
|
return title.replace(/_/g, " ").trim();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Turn an article title into a stable, id-safe slug. */
|
|||
|
|
export function slugifyArticle(title: string): string {
|
|||
|
|
return title
|
|||
|
|
.normalize("NFKD")
|
|||
|
|
.replace(/[̀-ͯ]/g, "") // strip combining diacritics
|
|||
|
|
.toLowerCase()
|
|||
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|||
|
|
.replace(/^-+|-+$/g, "");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Scan wikitext for `{{<TemplateName>...}}` invocations, returning each body
|
|||
|
|
* (text between the outer braces) with balanced-brace handling so nested
|
|||
|
|
* templates like `{{flagicon|ITA}}` don't terminate the capture early.
|
|||
|
|
*/
|
|||
|
|
export function extractTemplates(wikitext: string, namePrefixes: string[]): { name: string; body: string }[] {
|
|||
|
|
const results: { name: string; body: string }[] = [];
|
|||
|
|
for (let i = 0; i < wikitext.length - 1; i++) {
|
|||
|
|
if (wikitext[i] !== "{" || wikitext[i + 1] !== "{") continue;
|
|||
|
|
const afterBraces = wikitext.slice(i + 2);
|
|||
|
|
const match = afterBraces.match(/^\s*([A-Za-z0-9 _-]+)/);
|
|||
|
|
const name = match?.[1]?.trim() ?? "";
|
|||
|
|
if (!namePrefixes.some((p) => name.startsWith(p))) continue;
|
|||
|
|
|
|||
|
|
// Walk forward tracking {{ }} depth to find the matching close.
|
|||
|
|
let depth = 0;
|
|||
|
|
let j = i;
|
|||
|
|
for (; j < wikitext.length - 1; j++) {
|
|||
|
|
if (wikitext[j] === "{" && wikitext[j + 1] === "{") {
|
|||
|
|
depth++;
|
|||
|
|
j++;
|
|||
|
|
} else if (wikitext[j] === "}" && wikitext[j + 1] === "}") {
|
|||
|
|
depth--;
|
|||
|
|
j++;
|
|||
|
|
if (depth === 0) break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const body = wikitext.slice(i + 2, j - 1);
|
|||
|
|
results.push({ name, body });
|
|||
|
|
i = j; // continue past this template
|
|||
|
|
}
|
|||
|
|
return results;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Split a template body into `key=value` params on top-level `|` only —
|
|||
|
|
* pipes inside `{{…}}` or `[[…]]` are part of a value (flag templates, piped
|
|||
|
|
* wikilinks) and must not split.
|
|||
|
|
*/
|
|||
|
|
export function parseTemplateParams(body: string): Map<string, string> {
|
|||
|
|
const params = new Map<string, string>();
|
|||
|
|
let braceDepth = 0;
|
|||
|
|
let linkDepth = 0;
|
|||
|
|
let current = "";
|
|||
|
|
const parts: string[] = [];
|
|||
|
|
|
|||
|
|
for (let i = 0; i < body.length; i++) {
|
|||
|
|
const two = body.slice(i, i + 2);
|
|||
|
|
if (two === "{{") { braceDepth++; current += two; i++; continue; }
|
|||
|
|
if (two === "}}") { braceDepth--; current += two; i++; continue; }
|
|||
|
|
if (two === "[[") { linkDepth++; current += two; i++; continue; }
|
|||
|
|
if (two === "]]") { linkDepth--; current += two; i++; continue; }
|
|||
|
|
if (body[i] === "|" && braceDepth === 0 && linkDepth === 0) {
|
|||
|
|
parts.push(current);
|
|||
|
|
current = "";
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
current += body[i];
|
|||
|
|
}
|
|||
|
|
parts.push(current);
|
|||
|
|
|
|||
|
|
for (const part of parts) {
|
|||
|
|
const eq = part.indexOf("=");
|
|||
|
|
if (eq === -1) continue;
|
|||
|
|
const key = part.slice(0, eq).trim();
|
|||
|
|
const value = part.slice(eq + 1).trim();
|
|||
|
|
if (key) params.set(key, value);
|
|||
|
|
}
|
|||
|
|
return params;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Parse a bracket `team` value into a player + whether it won.
|
|||
|
|
* Returns null for an empty/Bye slot.
|
|||
|
|
*/
|
|||
|
|
export function parsePlayerCell(value: string): { player: FetchedPlayer; isWinner: boolean } | null {
|
|||
|
|
if (!value) return null;
|
|||
|
|
// Winner detection: only the winning slot's name is bolded.
|
|||
|
|
const isWinner = value.includes("'''");
|
|||
|
|
let text = value.replace(/'''/g, "");
|
|||
|
|
|
|||
|
|
// Extract the wikilink FIRST, before stripping templates. A team cell may wrap
|
|||
|
|
// the flag + link in a display template, e.g.
|
|||
|
|
// {{nowrap|{{flagicon|ESP}} [[Alejandro Davidovich Fokina|A Davidovich Fokina]]}}
|
|||
|
|
// Blanket template-stripping would, after removing the inner {{flagicon}}, treat
|
|||
|
|
// the outer {{nowrap| … [[link]] … }} as a single template and delete the link
|
|||
|
|
// with it — yielding an empty cell (TBD). Pulling the link out first avoids that.
|
|||
|
|
const linkMatch = text.match(/\[\[([^\]]+)\]\]/);
|
|||
|
|
let name: string;
|
|||
|
|
let externalId: string | null;
|
|||
|
|
if (linkMatch) {
|
|||
|
|
const [target, display] = linkMatch[1].split("|");
|
|||
|
|
externalId = target.trim();
|
|||
|
|
name = (display ?? target).trim();
|
|||
|
|
} else {
|
|||
|
|
// No link: unwrap display templates ({{nowrap|X}}) to their content, drop flag
|
|||
|
|
// templates, and keep whatever plain text remains (e.g. an unlinked qualifier).
|
|||
|
|
let prev: string;
|
|||
|
|
do {
|
|||
|
|
prev = text;
|
|||
|
|
text = text.replace(/\{\{\s*(?:nowrap|nobr)\s*\|([^{}]*)\}\}/gi, "$1");
|
|||
|
|
} while (text !== prev);
|
|||
|
|
do {
|
|||
|
|
prev = text;
|
|||
|
|
text = text.replace(/\{\{[^{}]*\}\}/g, " ");
|
|||
|
|
} while (text !== prev);
|
|||
|
|
name = text.replace(/\[\[|\]\]/g, "").trim();
|
|||
|
|
externalId = null;
|
|||
|
|
}
|
|||
|
|
name = name.replace(/\s+/g, " ").trim();
|
|||
|
|
// Treat empty slots and not-yet-determined placeholders (an unfilled qualifier,
|
|||
|
|
// a bye, a TBD feeder) as no player → the bracket shows TBD rather than
|
|||
|
|
// inventing a shared "Qualifier" participant across many slots.
|
|||
|
|
if (!name || /^(bye|tbd|qualifier|to be determined)$/i.test(name)) return null;
|
|||
|
|
return { player: { name, externalId, seed: null }, isWinner };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Build matches for one round of a bracket. Within a round, consecutive slots
|
|||
|
|
* pair up: (team1,team2)=match1, (team3,team4)=match2, …
|
|||
|
|
*/
|
|||
|
|
function parseRound(
|
|||
|
|
params: Map<string, string>,
|
|||
|
|
rdIndex: number,
|
|||
|
|
roundName: string,
|
|||
|
|
idPrefix: string,
|
|||
|
|
matchNumberOffset: number,
|
|||
|
|
): FetchedDrawMatch[] {
|
|||
|
|
// Collect team slot tokens present for this round, preserving the raw token
|
|||
|
|
// (slots are zero-padded in the compact section brackets, e.g. "team01", but
|
|||
|
|
// unpadded in the finals bracket, e.g. "team1"). Sort by numeric value.
|
|||
|
|
const slots: string[] = [];
|
|||
|
|
for (const key of params.keys()) {
|
|||
|
|
const m = key.match(new RegExp(`^RD${rdIndex}-team(\\d+)$`));
|
|||
|
|
if (m) slots.push(m[1]);
|
|||
|
|
}
|
|||
|
|
slots.sort((a, b) => Number(a) - Number(b));
|
|||
|
|
|
|||
|
|
const matches: FetchedDrawMatch[] = [];
|
|||
|
|
for (let p = 0; p < slots.length; p += 2) {
|
|||
|
|
const slot1 = slots[p];
|
|||
|
|
const slot2 = slots[p + 1];
|
|||
|
|
const cell1 = parsePlayerCell(params.get(`RD${rdIndex}-team${slot1}`) ?? "");
|
|||
|
|
const cell2 = slot2 !== undefined ? parsePlayerCell(params.get(`RD${rdIndex}-team${slot2}`) ?? "") : null;
|
|||
|
|
|
|||
|
|
const seed1 = params.get(`RD${rdIndex}-seed${slot1}`);
|
|||
|
|
const seed2 = slot2 !== undefined ? params.get(`RD${rdIndex}-seed${slot2}`) : undefined;
|
|||
|
|
const player1 = cell1 ? { ...cell1.player, seed: parseSeed(seed1) } : null;
|
|||
|
|
const player2 = cell2 ? { ...cell2.player, seed: parseSeed(seed2) } : null;
|
|||
|
|
|
|||
|
|
let winner: 1 | 2 | null = null;
|
|||
|
|
if (cell1?.isWinner) winner = 1;
|
|||
|
|
else if (cell2?.isWinner) winner = 2;
|
|||
|
|
|
|||
|
|
const matchNumber = matchNumberOffset + matches.length + 1;
|
|||
|
|
matches.push({
|
|||
|
|
externalMatchId: `${idPrefix}:${roundName.replace(/\s+/g, "-")}:m${matchNumber}`,
|
|||
|
|
round: roundName,
|
|||
|
|
matchNumber,
|
|||
|
|
player1,
|
|||
|
|
player2,
|
|||
|
|
winner,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
return matches;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function parseSeed(raw: string | undefined): number | null {
|
|||
|
|
if (!raw) return null;
|
|||
|
|
const n = parseInt(raw.replace(/[^0-9]/g, ""), 10);
|
|||
|
|
return Number.isFinite(n) ? n : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Parse a full Grand Slam singles draw article into a `FetchedDraw`.
|
|||
|
|
* @param wikitext raw article wikitext
|
|||
|
|
* @param articleTitle used to build stable match ids
|
|||
|
|
*/
|
|||
|
|
export function parseTennisDrawWikitext(wikitext: string, articleTitle: string): FetchedDraw {
|
|||
|
|
const slug = slugifyArticle(articleTitle);
|
|||
|
|
const sectionBrackets = extractTemplates(wikitext, ["16TeamBracket"]);
|
|||
|
|
const finalsBrackets = extractTemplates(wikitext, ["8TeamBracket"]);
|
|||
|
|
|
|||
|
|
if (finalsBrackets.length !== 1 || sectionBrackets.length !== 8) {
|
|||
|
|
throw new Error(
|
|||
|
|
`Unexpected tennis draw layout in "${articleTitle}": found ${sectionBrackets.length} ` +
|
|||
|
|
`16-team section bracket(s) and ${finalsBrackets.length} 8-team finals bracket(s); ` +
|
|||
|
|
"expected 8 sections + 1 finals. Template variant may be unsupported.",
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Accumulate matches per round across all sections (section order is cosmetic).
|
|||
|
|
const roundMatches = new Map<string, FetchedDrawMatch[]>();
|
|||
|
|
const pushMatches = (round: string, ms: FetchedDrawMatch[]) => {
|
|||
|
|
const existing = roundMatches.get(round) ?? [];
|
|||
|
|
roundMatches.set(round, existing.concat(ms));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
sectionBrackets.forEach((bracket, sectionIdx) => {
|
|||
|
|
const params = parseTemplateParams(bracket.body);
|
|||
|
|
SECTION_ROUNDS.forEach((roundName, rdIdx) => {
|
|||
|
|
const offset = roundMatches.get(roundName)?.length ?? 0;
|
|||
|
|
pushMatches(roundName, parseRound(params, rdIdx + 1, roundName, `${slug}:s${sectionIdx}`, offset));
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const finalsParams = parseTemplateParams(finalsBrackets[0].body);
|
|||
|
|
FINALS_ROUNDS.forEach((roundName, rdIdx) => {
|
|||
|
|
pushMatches(roundName, parseRound(finalsParams, rdIdx + 1, roundName, `${slug}:f`, 0));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const orderedRoundNames = [...SECTION_ROUNDS, ...FINALS_ROUNDS];
|
|||
|
|
const rounds: FetchedRound[] = orderedRoundNames.map((roundName) => ({
|
|||
|
|
roundName,
|
|||
|
|
matches: roundMatches.get(roundName) ?? [],
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
return { templateId: "tennis_128", rounds };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export class WikipediaTennisAdapter implements DrawSyncAdapter {
|
|||
|
|
async fetchDraw(sourceKey: string): Promise<FetchedDraw> {
|
|||
|
|
const title = articleTitleFromInput(sourceKey);
|
|||
|
|
const url = `${WIKIPEDIA_API}?action=parse&prop=wikitext&format=json&formatversion=2&redirects=1&page=${encodeURIComponent(title)}`;
|
|||
|
|
const response = await fetch(url, { headers: { "User-Agent": USER_AGENT } });
|
|||
|
|
if (!response.ok) {
|
|||
|
|
throw new Error(`Wikipedia API returned ${response.status}: ${response.statusText}`);
|
|||
|
|
}
|
|||
|
|
const json = (await response.json()) as {
|
|||
|
|
error?: { info?: string };
|
|||
|
|
parse?: { wikitext?: string };
|
|||
|
|
};
|
|||
|
|
if (json.error) {
|
|||
|
|
throw new Error(`Wikipedia API error for "${title}": ${json.error.info ?? "unknown error"}`);
|
|||
|
|
}
|
|||
|
|
const wikitext = json.parse?.wikitext;
|
|||
|
|
if (!wikitext) {
|
|||
|
|
throw new Error(`No wikitext returned for "${title}"`);
|
|||
|
|
}
|
|||
|
|
return parseTennisDrawWikitext(wikitext, title);
|
|||
|
|
}
|
|||
|
|
}
|