/** * 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 `{{...}}` 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 { const params = new Map(); 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, 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(); 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 { 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); } }