brackt/app/services/match-sync/wikipedia-tennis.ts
Chris Parsons 81d063faa1
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m13s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m23s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
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 21:48:00 -07:00

307 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
}
}