brackt/app/services/match-sync/wikipedia-tennis.ts

308 lines
12 KiB
TypeScript
Raw Normal View History

Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia Add a per-event "Sync Draw" that pulls a tennis major's full 128-player draw from its Wikipedia article, auto-creates/links participants (and propagates them to every linked sibling season), builds the bracket, and runs the qualifying-points scorer. Re-running advances the bracket and scoring as matches complete. Core - match-sync: WikipediaTennisAdapter + wikitext bracket parser, DrawSync DTOs, syncTennisDraw orchestrator, pure draw->rows mapping - playoff-match: populateBracketFromDraw (idempotent upsert on externalMatchId) - scoring_events.externalSourceKey column (Wikipedia article; migration 0123) - admin bracket "Sync Draw" card (accepts URL or title) + cron pass Scoring fix - deriveBracketQualifyingStates only floors players who have reached the scoring stage; for deep brackets (tennis_128) early-round losers earn 0 QP instead of a phantom 9th-place floor. CS2/simple_8 behavior preserved (gated on rounds[0].isScoring). Re-sync reconciles stale QP rows in a transaction. Matching & parsing - accent-folding in normalizeTeamName; strip Wikipedia "(tennis)" disambiguators; treat Bye/TBD/Qualifier as TBD; extract wikilink before template-stripping so {{nowrap}}-wrapped players parse Admin UX - dry-run preview (matched / will-create / possible duplicates / unfilled slots) with inline "rename existing" / "create as new" resolution via fetcher (no full reload) Tests: parser vs real 2025 Wimbledon fixture, draw mapping, tennis_128 scoring, accent/disambiguator/nowrap parsing, URL parsing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:32:35 -07:00
/**
* 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);
}
}