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>
307 lines
12 KiB
TypeScript
307 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);
|
||
}
|
||
}
|