feat: replace Kaggle CSV with Wikipedia scraper for historical match data

Add scripts/scrape-wikipedia.ts that fetches all 22 World Cups (1930–2022)
from English Wikipedia via MediaWiki API, handles group sub-pages, AET/penalty
detection, and goal parsing, writing openfootball-format JSON to app/data/openfootball/.

Rewrite scripts/seed.ts to read these local JSON files instead of the Kaggle
CSV, producing 965 matches and 2716 goals with per-group assignments for all
historical tournaments (enabling group standings on tournament pages).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 11:39:53 +02:00
parent 83b1ad3e35
commit 5dcd22ad22
88 changed files with 95625 additions and 127 deletions
+549
View File
@@ -0,0 +1,549 @@
import { load } from 'cheerio'
import type { CheerioAPI } from 'cheerio'
import type { Cheerio } from 'cheerio'
import type { Element } from 'domhandler'
import { mkdirSync, writeFileSync } from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DATA_DIR = path.join(__dirname, '../app/data/openfootball')
const YEARS = [
1930,1934,1938,1950,1954,1958,1962,1966,1970,1974,
1978,1982,1986,1990,1994,1998,2002,2006,2010,2014,2018,2022,
]
const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
// ── Types ──────────────────────────────────────────────────────────────────
type Goal = {
name: string
minute?: number
offset?: number
penalty?: boolean
owngoal?: boolean
}
type ScoreObj = {
ft?: [number, number]
et?: [number, number]
p?: [number, number]
}
type Match = {
round: string
group?: string
date?: string
time?: string
team1: string
team2: string
score?: ScoreObj
goals1?: Goal[]
goals2?: Goal[]
ground?: string
}
type Stadium = { name: string; city: string }
type Player = { name: string; number?: number; pos?: string; date_of_birth?: string }
type Squad = { name: string; players: Player[] }
type Group = { name: string; teams: string[] }
// ── Fetch ──────────────────────────────────────────────────────────────────
async function fetchWikiHtml(page: string, retries = 5): Promise<string | null> {
const url = `https://en.wikipedia.org/w/api.php?action=parse&page=${encodeURIComponent(page)}&format=json&prop=text&disabletoc=1`
for (let attempt = 0; attempt < retries; attempt++) {
try {
if (attempt > 0) await delay(3000 * attempt)
const res = await fetch(url, { headers: { 'User-Agent': 'WorldCupScraper/1.0 (github.com/worldcup)' } })
if (!res.ok) continue
const data = await res.json() as { parse?: { text?: { '*': string } } }
const html = data?.parse?.text?.['*']
if (html) return html
} catch {
// retry
}
}
return null
}
// ── Score parsing ──────────────────────────────────────────────────────────
function parseScoreText(text: string): [number, number] | null {
const m = text.match(/(\d+)\s*[\-]\s*(\d+)/)
if (!m) return null
return [parseInt(m[1]), parseInt(m[2])]
}
// ── Team name extraction ───────────────────────────────────────────────────
function extractTeam($: CheerioAPI, $cell: Cheerio<Element>): string {
let name = ''
$cell.find('a').each((_, a) => {
const $a = $(a)
if (!$a.find('img').length && $a.text().trim()) {
name = $a.text().trim()
return false
}
})
return name
}
// ── Goal parsing ───────────────────────────────────────────────────────────
function parseGoals($: CheerioAPI, $td: Cheerio<Element>): Goal[] {
const goals: Goal[] = []
$td.find('li').each((_, li) => {
const $li = $(li)
// Player name: first <a> NOT inside .fb-goal
let playerName = ''
$li.find('a').each((_, a) => {
if (!$(a).closest('.fb-goal').length) {
const t = $(a).text().trim()
if (t) { playerName = t; return false }
}
})
if (!playerName) return
const $fbGoal = $li.find('.fb-goal')
if (!$fbGoal.length) return
// Each direct child <span> inside .fb-goal (excluding image wrapper)
$fbGoal.children('span').each((_, span) => {
const $span = $(span)
if ($span.attr('typeof')) return // image wrapper
const text = $span.text()
const minMatch = text.match(/(\d+)(?:\+(\d+))?[']/)
if (!minMatch) return
const minute = parseInt(minMatch[1])
const offset = minMatch[2] ? parseInt(minMatch[2]) : 0
const isPen = text.includes('pen.')
const isOG = text.includes('o.g.')
const goal: Goal = { name: playerName }
if (!isNaN(minute)) goal.minute = minute
if (offset) goal.offset = offset
if (isPen) goal.penalty = true
if (isOG) goal.owngoal = true
goals.push(goal)
})
})
return goals
}
// ── Ground extraction ──────────────────────────────────────────────────────
function extractGround($: CheerioAPI, $box: Cheerio<Element>): string {
const $loc = $box.find('[itemprop="name address"]').first()
if ($loc.length) return $loc.text().trim()
return $box.find('.fright').first().text().split('\n')[0].trim()
}
function parseGroundParts(ground: string): { name: string; city: string } {
const commaIdx = ground.indexOf(',')
if (commaIdx !== -1) {
return {
name: ground.slice(0, commaIdx).trim(),
city: ground.slice(commaIdx + 1).trim(),
}
}
return { name: ground, city: '' }
}
// ── Footballbox parsing ────────────────────────────────────────────────────
function parseBox(
$: CheerioAPI,
$box: Cheerio<Element>,
round: string,
group: string | null,
): Match | null {
const team1 = extractTeam($, $box.find('.fhome'))
const team2 = extractTeam($, $box.find('.faway'))
if (!team1 || !team2) return null
const dateStr = $box.find('.bday, .dtstart').first().text().trim() || undefined
const timeText = $box.find('.ftime').first().text().trim()
const timeMatch = timeText.match(/(\d{2}:\d{2})/)
const timeStr = timeMatch?.[1]
const scoreText = $box.find('.fscore').first().text().trim()
const hasAET = scoreText.toLowerCase().includes('a.e.t.')
const scoreArr = parseScoreText(scoreText)
// Use first fgoals row only (exclude penalty shootout row)
const $regularRow = $box.find('tr.fgoals').first()
const goals1 = parseGoals($, $regularRow.find('.fhgoal'))
const goals2 = parseGoals($, $regularRow.find('.fagoal'))
// Penalty shootout score: row after "Penalties" header tr
let penScore: [number, number] | undefined
$box.find('tr').each((_, tr) => {
const $tr = $(tr)
if ($tr.find('th[colspan]').text().toLowerCase().includes('penalt')) {
const penText = $tr.next('tr').find('th').not('.fhome,.faway').first().text().trim()
const ps = parseScoreText(penText)
if (ps) penScore = ps
return false
}
})
let score: ScoreObj | undefined
if (scoreArr) {
if (hasAET) {
// scoreArr is ET total; compute FT from goals in ≤90 min
const ftGoals = (gs: Goal[], includeOG = false) =>
gs.filter(g => {
const w90 = g.minute === undefined || g.minute <= 90
return includeOG ? g.owngoal === true && w90 : !g.owngoal && w90
}).length
const ftHome = ftGoals(goals1) + ftGoals(goals2, true)
const ftAway = ftGoals(goals2) + ftGoals(goals1, true)
score = { ft: [ftHome, ftAway], et: scoreArr }
} else {
score = { ft: scoreArr }
}
if (penScore) score.p = penScore
}
const ground = extractGround($, $box) || undefined
return {
round,
...(group ? { group } : {}),
...(dateStr ? { date: dateStr } : {}),
...(timeStr ? { time: timeStr } : {}),
team1,
team2,
...(score ? { score } : {}),
...(goals1.length ? { goals1 } : {}),
...(goals2.length ? { goals2 } : {}),
...(ground ? { ground } : {}),
}
}
// ── Collect matches from a pre-loaded page ─────────────────────────────────
function collectBoxes(
$: CheerioAPI,
round: string,
group: string | null,
): Match[] {
const matches: Match[] = []
$('.footballbox').each((_, el) => {
const m = parseBox($, $(el), round, group)
if (m) matches.push(m)
})
return matches
}
// ── Section heading state machine ──────────────────────────────────────────
type State = {
active: boolean
round: string
group: string | null
}
function processHeading(text: string, level: number, state: State): void {
const t = text.toLowerCase().trim()
if (level === 2) {
if (/group stage/i.test(t) && !/second/i.test(t)) {
state.active = true; state.round = 'Group stage'; state.group = null
} else if (/first group stage/i.test(t)) {
state.active = true; state.round = 'Group stage'; state.group = null
} else if (/second group stage/i.test(t)) {
state.active = true; state.round = 'Second group stage'; state.group = null
} else if (t === 'final round') {
state.active = true; state.round = 'Final round'; state.group = null
} else if (/final tournament/i.test(t)) {
state.active = true; state.round = ''; state.group = null
} else if (/knock.?out stage/i.test(t)) {
state.active = true; state.round = ''; state.group = null
} else if (/round of 16/i.test(t)) {
state.active = true; state.round = 'Round of 16'; state.group = null
} else if (/quarter.final/i.test(t)) {
state.active = true; state.round = 'Quarter-finals'; state.group = null
} else if (/semi.final/i.test(t)) {
state.active = true; state.round = 'Semi-finals'; state.group = null
} else if (/third.place|match for third|play.off for third/i.test(t)) {
state.active = true; state.round = 'Third-place match'; state.group = null
} else if (t === 'final') {
state.active = true; state.round = 'Final'; state.group = null
} else {
state.active = false
}
return
}
if (!state.active) return
if (level === 3 || level === 4) {
if (/^group [a-h1-9]+$/i.test(t)) {
state.group = text.trim()
} else if (/round of 32/i.test(t)) {
state.round = 'Round of 32'; state.group = null
} else if (/round of 16/i.test(t)) {
state.round = 'Round of 16'; state.group = null
} else if (/quarter.final/i.test(t)) {
state.round = 'Quarter-finals'; state.group = null
} else if (/semi.final/i.test(t)) {
state.round = 'Semi-finals'; state.group = null
} else if (/third.place|match for third|play.off for third/i.test(t)) {
state.round = 'Third-place match'; state.group = null
} else if (t === 'final') {
state.round = 'Final'; state.group = null
}
// bracket, draw, seeding, replay → keep current state
}
}
// ── Main year scraper ──────────────────────────────────────────────────────
type YearResult = {
matches: Match[]
stadiums: Map<string, Stadium>
groups: Map<string, Set<string>>
}
async function scrapeYear(year: number, mainHtml: string): Promise<YearResult> {
const $ = load(mainHtml)
const matches: Match[] = []
const stadiums = new Map<string, Stadium>()
const groups = new Map<string, Set<string>>()
const state: State = { active: false, round: '', group: null }
// Maps group name → sub-page to fetch (if main page has no matches for that group)
const groupSubpages = new Map<string, string>()
// Groups that got at least one match from the main page
const groupsOnMainPage = new Set<string>()
function recordMatch(m: Match) {
matches.push(m)
if (m.group) groupsOnMainPage.add(m.group)
if (m.ground) {
const { name, city } = parseGroundParts(m.ground)
if (name && !stadiums.has(name)) stadiums.set(name, { name, city })
}
if (m.group) {
if (!groups.has(m.group)) groups.set(m.group, new Set())
groups.get(m.group)!.add(m.team1)
groups.get(m.group)!.add(m.team2)
}
}
// Walk elements in document order: headings, hatnotes, footballboxes
$('.mw-parser-output').find('div.mw-heading, .footballbox, .hatnote').each((_, el) => {
const $el = $(el)
if ($el.hasClass('mw-heading')) {
const $h = $el.find('h2, h3, h4').first()
const level = parseInt($h.prop('tagName')?.slice(1) ?? '9')
const text = $h.text().replace(/\[edit\]/g, '').trim()
processHeading(text, level, state)
} else if ($el.hasClass('hatnote') && $el.text().includes('Main article')) {
// Record sub-page link for current group context (for fallback if no main-page matches)
if (state.active && state.group) {
const link = $el.find('a[href^="/wiki/"]').first().attr('href')
if (link) {
const page = link.replace('/wiki/', '').split('#')[0]
if (/World_Cup_Group/i.test(page) && !groupSubpages.has(state.group)) {
groupSubpages.set(state.group, page)
}
}
}
} else if ($el.hasClass('footballbox')) {
if (!state.active) return
const round = state.round || state.group || 'Unknown'
const m = parseBox($, $el, round, state.group)
if (m) recordMatch(m)
}
})
// Fetch group sub-pages for any group that got 0 matches from main page
for (const [group, page] of groupSubpages) {
if (groupsOnMainPage.has(group)) continue
await delay(1200)
const subHtml = await fetchWikiHtml(page)
if (!subHtml) { process.stdout.write(`(failed: ${page}) `); continue }
// Determine the round for this group from the state machine result
// (we'll reconstruct from the main-page walk state — use the round that was active when this group was seen)
// Since we can't easily recover state here, we re-walk to find the round for this group
let round = 'Group stage'
let foundGroup = false
const stateTemp: State = { active: false, round: '', group: null }
$('.mw-parser-output').find('div.mw-heading').each((_, el) => {
const $h = $(el).find('h2, h3, h4').first()
const level = parseInt($h.prop('tagName')?.slice(1) ?? '9')
const text = $h.text().replace(/\[edit\]/g, '').trim()
processHeading(text, level, stateTemp)
if (stateTemp.group === group) {
round = stateTemp.round || 'Group stage'
foundGroup = true
return false
}
})
const $sub = load(subHtml)
const subMatches = collectBoxes($sub, round || 'Group stage', group)
for (const m of subMatches) {
recordMatch(m)
}
process.stdout.write(`[+${page.slice(-8)}] `)
}
return { matches, stadiums, groups }
}
// ── Squad page scraper ─────────────────────────────────────────────────────
function scrapeSquads(html: string): Squad[] {
const $ = load(html)
const squads: Squad[] = []
let currentTeam: Squad | null = null
$('.mw-parser-output').find('div.mw-heading, tr.nat-fs-player').each((_, el) => {
const $el = $(el)
if ($el.hasClass('mw-heading')) {
const $h = $el.find('h3, h4').first()
if (!$h.length) return
const level = parseInt($h.prop('tagName')?.slice(1) ?? '9')
if (level !== 3) return
const name = $h.text().replace(/\[edit\]/g, '').trim()
if (/^group /i.test(name)) return // skip group headers
currentTeam = { name, players: [] }
squads.push(currentTeam)
return
}
if (!currentTeam) return
let number: number | undefined
let pos: string | undefined
let playerName = ''
let dob: string | undefined
$el.find('td, th[scope="row"]').each((i, td) => {
const $td = $(td)
const text = $td.text().trim()
if ($td.is('th[scope="row"]')) {
playerName = $td.find('a').first().text().trim() || text
} else if (i === 0 && !playerName) {
const n = parseInt(text)
if (!isNaN(n)) number = n
} else if (i === 1 && !playerName && !pos) {
const posLink = $td.find('a').first().text().trim()
if (['GK', 'DF', 'MF', 'FW'].includes(posLink)) pos = posLink
}
const $bday = $td.find('.bday')
if ($bday.length) dob = $bday.text().trim()
})
if (!playerName) return
const player: Player = { name: playerName }
if (number !== undefined) player.number = number
if (pos) player.pos = pos
if (dob) player.date_of_birth = dob
currentTeam.players.push(player)
})
return squads
}
// ── Output ─────────────────────────────────────────────────────────────────
function writeOutput(
year: number,
matches: Match[],
stadiums: Map<string, Stadium>,
groups: Map<string, Set<string>>,
squads: Squad[],
): void {
const dir = path.join(DATA_DIR, String(year))
mkdirSync(dir, { recursive: true })
writeFileSync(
path.join(dir, 'worldcup.json'),
JSON.stringify({ matches }, null, 2),
'utf-8',
)
if (stadiums.size > 0) {
writeFileSync(
path.join(dir, 'worldcup.stadiums.json'),
JSON.stringify({ stadiums: Array.from(stadiums.values()) }, null, 2),
'utf-8',
)
}
const groupList: Group[] = []
groups.forEach((teams, name) => {
groupList.push({ name, teams: Array.from(teams) })
})
if (groupList.length > 0) {
writeFileSync(
path.join(dir, 'worldcup.groups.json'),
JSON.stringify({ groups: groupList }, null, 2),
'utf-8',
)
}
if (squads.length > 0) {
writeFileSync(
path.join(dir, 'worldcup.squads.json'),
JSON.stringify(squads, null, 2),
'utf-8',
)
}
}
// ── Entry point ────────────────────────────────────────────────────────────
async function main() {
const onlyYear = process.argv[2] ? parseInt(process.argv[2]) : null
const yearsToScrape = onlyYear ? [onlyYear] : YEARS
console.log(`Scraping ${yearsToScrape.length} World Cup(s) from Wikipedia...`)
for (const year of yearsToScrape) {
process.stdout.write(` ${year}... `)
const mainHtml = await fetchWikiHtml(`${year}_FIFA_World_Cup`)
if (!mainHtml) { console.log('FAILED'); continue }
const { matches, stadiums, groups } = await scrapeYear(year, mainHtml)
await delay(600)
const squadHtml = await fetchWikiHtml(`${year}_FIFA_World_Cup_squads`)
const squads = squadHtml ? scrapeSquads(squadHtml) : []
writeOutput(year, matches, stadiums, groups, squads)
console.log(`${matches.length} matches, ${stadiums.size} stadiums, ${groups.size} groups, ${squads.length} teams`)
await delay(600)
}
console.log('\nDone! Files written to app/data/openfootball/{year}/')
}
main().catch(e => { console.error(e); process.exit(1) })
+193 -127
View File
@@ -1,16 +1,23 @@
import postgres from 'postgres'
import { drizzle } from 'drizzle-orm/postgres-js'
import { sql } from 'drizzle-orm'
import { readFileSync } from 'fs'
import { readFileSync, existsSync } from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { getIso } from '../lib/iso-codes'
const DATABASE_URL = process.env.DATABASE_URL ?? 'postgres://wc:wc@localhost:5432/worldcup'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DATA_DIR = path.join(__dirname, '../app/data/kaggle')
const DATA_DIR = path.join(__dirname, '../app/data')
const KAGGLE_DIR = path.join(DATA_DIR, 'kaggle')
const WC_DIR = path.join(DATA_DIR, 'openfootball')
// Third/fourth place not present in Kaggle world_cup.csv
const YEARS = [
1930,1934,1938,1950,1954,1958,1962,1966,1970,1974,
1978,1982,1986,1990,1994,1998,2002,2006,2010,2014,2018,2022,
]
// Third/fourth place not reliably in source data for older years
const PLACEMENTS: Record<number, { third?: string; fourth?: string }> = {
1930: { third: 'USA', fourth: 'Yugoslavia' },
1934: { third: 'Germany', fourth: 'Austria' },
@@ -35,7 +42,7 @@ const PLACEMENTS: Record<number, { third?: string; fourth?: string }> = {
2022: { third: 'Croatia', fourth: 'Morocco' },
}
// Normalize Kaggle team names to match openfootball / our canonical names
// Normalize team names from Wikipedia to canonical DB names
const TEAM_ALIASES: Record<string, string> = {
'West Germany': 'Germany',
'Korea Republic': 'South Korea',
@@ -46,7 +53,7 @@ function normTeam(name: string): string {
return TEAM_ALIASES[name] ?? name
}
// Minimal RFC-4180 CSV parser — no external dependency needed
// Minimal RFC-4180 CSV parser
function parseCsv(content: string): Record<string, string>[] {
const rows: string[][] = []
let row: string[] = []
@@ -78,38 +85,36 @@ function parseCsv(content: string): Record<string, string>[] {
.map(r => Object.fromEntries(headers.map((h, i) => [h.trim(), (r[i] ?? '').trim()])))
}
type GoalEntry = { name: string; minute: number | null; offset: number; isPenalty: boolean; isOwnGoal: boolean }
// Parse "Player Name · 57" or "Player (OG) · 90+3" → GoalEntry
function parseGoalStr(entry: string, isPenalty = false, isOwnGoal = false): GoalEntry | null {
const dot = entry.lastIndexOf('·')
if (dot === -1) return null
const name = entry.slice(0, dot).trim()
.replace(/\s*\(P\)\s*$/, '').replace(/\s*\(OG\)\s*$/, '').trim()
if (!name) return null
const minRaw = entry.slice(dot + 1).trim()
const plusIdx = minRaw.indexOf('+')
let minute: number | null, offset = 0
if (plusIdx !== -1) {
minute = parseInt(minRaw.slice(0, plusIdx))
offset = parseInt(minRaw.slice(plusIdx + 1)) || 0
} else {
const m = parseInt(minRaw)
minute = isNaN(m) ? null : m
}
return { name, minute, offset, isPenalty, isOwnGoal }
function readJson<T>(filePath: string): T | null {
if (!existsSync(filePath)) return null
try { return JSON.parse(readFileSync(filePath, 'utf-8')) as T } catch { return null }
}
function parseGoalCol(col: string, isPenalty = false, isOwnGoal = false): GoalEntry[] {
if (!col?.trim()) return []
return col.split('|').map(e => parseGoalStr(e.trim(), isPenalty, isOwnGoal)).filter(Boolean) as GoalEntry[]
// ── Types matching scrape-wikipedia.ts output ──────────────────────────────
type RawGoal = { name: string; minute?: string | number; offset?: number; penalty?: boolean; owngoal?: boolean }
type RawScore = { ft?: number[]; ht?: number[]; et?: number[]; p?: number[] }
type RawMatch = {
round?: string; date?: string; time?: string;
team1: string; team2: string; score?: RawScore;
goals1?: RawGoal[]; goals2?: RawGoal[];
group?: string; ground?: string;
}
type RawData = { matches: RawMatch[] }
type RawStadiums = { stadiums: { name: string; city: string; cc?: string; capacity?: number; timezone?: string; coords?: string }[] }
type RawSquad = { name: string; players: { name: string; number?: number; pos?: string; date_of_birth?: string }[] }
function parseScore(score: RawScore | undefined) {
if (!score) return {}
if (Array.isArray(score)) return { ft: score as number[] }
return { ft: score.ft, ht: score.ht, et: score.et, p: score.p }
}
async function run() {
const client = postgres(DATABASE_URL, { max: 5 })
const db = drizzle(client)
// Create tables (mirrors sync.ts DDL — runs first on a fresh DB)
// Create tables
await db.execute(sql`
CREATE TABLE IF NOT EXISTS tournaments (
year INTEGER PRIMARY KEY,
@@ -203,7 +208,6 @@ async function run() {
const force = process.argv.includes('--force') || process.argv.includes('-f')
// Skip if already seeded (idempotency check)
if (!force) {
const existing = await db.execute(sql`SELECT COUNT(*)::int AS cnt FROM tournaments WHERE year < 2026`)
if ((existing[0] as { cnt: number }).cnt > 0) {
@@ -216,20 +220,24 @@ async function run() {
if (force) {
console.log('--force: clearing historical data...')
await db.execute(sql`DELETE FROM goals WHERE match_id IN (SELECT id FROM matches WHERE tournament_year < 2026)`)
await db.execute(sql`DELETE FROM squads WHERE tournament_year < 2026`)
await db.execute(sql`DELETE FROM group_standings WHERE tournament_year < 2026`)
await db.execute(sql`DELETE FROM stadiums WHERE tournament_year < 2026`)
await db.execute(sql`DELETE FROM matches WHERE tournament_year < 2026`)
await db.execute(sql`DELETE FROM tournaments WHERE year < 2026`)
}
console.log('Seeding from Kaggle data (19302022)...')
console.log('Seeding historical data (19302022)...')
const teamCache = new Map<string, number>()
async function upsertTeam(rawName: string): Promise<number> {
const name = normTeam(rawName)
if (teamCache.has(name)) return teamCache.get(name)!
const iso2 = getIso(name)
const [row] = await db.execute(sql`
INSERT INTO teams (name, iso2)
VALUES (${name}, ${getIso(name) ?? null})
VALUES (${name}, ${iso2 ?? null})
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
RETURNING id
`)
@@ -238,8 +246,8 @@ async function run() {
return id
}
// 1. Tournaments from world_cup.csv
const wcRows = parseCsv(readFileSync(path.join(DATA_DIR, 'world_cup.csv'), 'utf-8'))
// 1. Tournaments from world_cup.csv (host, winner, runner_up)
const wcRows = parseCsv(readFileSync(path.join(KAGGLE_DIR, 'world_cup.csv'), 'utf-8'))
for (const r of wcRows) {
const year = parseInt(r['Year'])
if (isNaN(year)) continue
@@ -247,135 +255,193 @@ async function run() {
const runnerUp = normTeam(r['Runner-Up'] || '')
const p = PLACEMENTS[year] ?? {}
await db.execute(sql`
INSERT INTO tournaments (year, host, winner, runner_up, third_place, fourth_place, teams_count, matches_count)
INSERT INTO tournaments (year, host, winner, runner_up, third_place, fourth_place, teams_count)
VALUES (
${year}, ${r['Host']},
${winner || null}, ${runnerUp || null},
${p.third ?? null}, ${p.fourth ?? null},
${parseInt(r['Teams']) || null}, ${parseInt(r['Matches']) || null}
${parseInt(r['Teams']) || null}
)
ON CONFLICT (year) DO UPDATE SET
host = EXCLUDED.host,
winner = EXCLUDED.winner,
runner_up = EXCLUDED.runner_up,
host = EXCLUDED.host,
winner = EXCLUDED.winner,
runner_up = EXCLUDED.runner_up,
third_place = EXCLUDED.third_place,
fourth_place = EXCLUDED.fourth_place,
teams_count = EXCLUDED.teams_count,
matches_count = EXCLUDED.matches_count
teams_count = EXCLUDED.teams_count
`)
}
// 2. Matches + goals from matches_1930_2022.csv
const matchRows = parseCsv(readFileSync(path.join(DATA_DIR, 'matches_1930_2022.csv'), 'utf-8'))
// 2. Per-year match/stadium/squad data from openfootball JSON files
let totalMatches = 0
let totalGoals = 0
let totalMatches = 0, totalGoals = 0
for (const r of matchRows) {
const year = parseInt(r['Year'])
if (isNaN(year)) continue
const t1Id = await upsertTeam(r['home_team'])
const t2Id = await upsertTeam(r['away_team'])
const homeScore = r['home_score'] !== '' ? parseInt(r['home_score']) : null
const awayScore = r['away_score'] !== '' ? parseInt(r['away_score']) : null
const homePen = r['home_penalty'] !== '' ? parseInt(r['home_penalty']) : null
const awayPen = r['away_penalty'] !== '' ? parseInt(r['away_penalty']) : null
const dateStr = r['Date'] || null
// Parse all goal columns
const homeGoals = parseGoalCol(r['home_goal'])
const awayGoals = parseGoalCol(r['away_goal'])
const homePenGoals = parseGoalCol(r['home_penalty_goal'], true)
const awayPenGoals = parseGoalCol(r['away_penalty_goal'], true)
// home_own_goal = home player scored OG → goal credited to AWAY team
const homeOgGoals = parseGoalCol(r['home_own_goal'], false, true)
// away_own_goal = away player scored OG → goal credited to HOME team
const awayOgGoals = parseGoalCol(r['away_own_goal'], false, true)
// Determine FT vs ET score split from goal minutes
const allGoals = [...homeGoals, ...awayGoals, ...homePenGoals, ...awayPenGoals]
const hasEt = allGoals.some(g => g.minute !== null && g.minute > 90)
let scoreFtHome: number | null, scoreFtAway: number | null
let scoreEtHome: number | null = null, scoreEtAway: number | null = null
if (hasEt) {
// Compute FT from goals in minutes 190
const ftGoalCount = (goals: GoalEntry[]) =>
goals.filter(g => g.minute === null || g.minute <= 90).length
scoreFtHome = ftGoalCount(homeGoals) + ftGoalCount(homePenGoals) + ftGoalCount(awayOgGoals)
scoreFtAway = ftGoalCount(awayGoals) + ftGoalCount(awayPenGoals) + ftGoalCount(homeOgGoals)
scoreEtHome = homeScore
scoreEtAway = awayScore
} else {
scoreFtHome = homeScore
scoreFtAway = awayScore
for (const year of YEARS) {
const yearDir = path.join(WC_DIR, String(year))
const mainData = readJson<RawData>(path.join(yearDir, 'worldcup.json'))
if (!mainData?.matches) {
console.log(` ${year}: no data file, skipping`)
continue
}
const [matchRow] = await db.execute(sql`
INSERT INTO matches (
tournament_year, round, date, team1_id, team2_id,
score_ft_home, score_ft_away, score_et_home, score_et_away,
score_p_home, score_p_away, is_quali_playoff
) VALUES (
${year}, ${r['Round'] || 'Unknown'}, ${dateStr},
${t1Id}, ${t2Id},
${scoreFtHome}, ${scoreFtAway}, ${scoreEtHome}, ${scoreEtAway},
${homePen}, ${awayPen}, false
)
ON CONFLICT (tournament_year, team1_id, team2_id, date, is_quali_playoff) DO UPDATE SET
round = EXCLUDED.round,
score_ft_home = EXCLUDED.score_ft_home,
score_ft_away = EXCLUDED.score_ft_away,
score_et_home = EXCLUDED.score_et_home,
score_et_away = EXCLUDED.score_et_away,
score_p_home = EXCLUDED.score_p_home,
score_p_away = EXCLUDED.score_p_away
RETURNING id
`)
const matchId = (matchRow as { id: number }).id
let matchCount = 0, goalCount = 0
await db.execute(sql`DELETE FROM goals WHERE match_id = ${matchId}`)
// Stadiums
const stadiumsData = readJson<RawStadiums>(path.join(yearDir, 'worldcup.stadiums.json'))
if (stadiumsData?.stadiums) {
for (const s of stadiumsData.stadiums) {
await db.execute(sql`
INSERT INTO stadiums (tournament_year, name, city)
VALUES (${year}, ${s.name}, ${s.city ?? null})
ON CONFLICT DO NOTHING
`)
}
}
// home team goals (+ away player own goals that benefit home)
for (const g of [...homeGoals, ...homePenGoals, ...awayOgGoals]) {
await db.execute(sql`
INSERT INTO goals (match_id, team_id, player_name, minute, minute_offset, is_penalty, is_own_goal)
VALUES (${matchId}, ${t1Id}, ${g.name}, ${g.minute}, ${g.offset}, ${g.isPenalty}, ${g.isOwnGoal})
// Matches and goals
for (const m of mainData.matches) {
const t1Id = await upsertTeam(m.team1)
const t2Id = await upsertTeam(m.team2)
const score = parseScore(m.score)
const [matchRow] = await db.execute(sql`
INSERT INTO matches (
tournament_year, round, group_name, date, time_local,
team1_id, team2_id,
score_ft_home, score_ft_away,
score_ht_home, score_ht_away,
score_et_home, score_et_away,
score_p_home, score_p_away,
is_quali_playoff
) VALUES (
${year}, ${m.round ?? 'Unknown'}, ${m.group ?? null},
${m.date ?? null}, ${m.time ?? null},
${t1Id}, ${t2Id},
${score.ft?.[0] ?? null}, ${score.ft?.[1] ?? null},
${score.ht?.[0] ?? null}, ${score.ht?.[1] ?? null},
${score.et?.[0] ?? null}, ${score.et?.[1] ?? null},
${score.p?.[0] ?? null}, ${score.p?.[1] ?? null},
false
)
ON CONFLICT (tournament_year, team1_id, team2_id, date, is_quali_playoff) DO UPDATE SET
round = EXCLUDED.round,
group_name = COALESCE(EXCLUDED.group_name, matches.group_name),
time_local = COALESCE(EXCLUDED.time_local, matches.time_local),
score_ft_home = COALESCE(EXCLUDED.score_ft_home, matches.score_ft_home),
score_ft_away = COALESCE(EXCLUDED.score_ft_away, matches.score_ft_away),
score_ht_home = COALESCE(EXCLUDED.score_ht_home, matches.score_ht_home),
score_ht_away = COALESCE(EXCLUDED.score_ht_away, matches.score_ht_away),
score_et_home = COALESCE(EXCLUDED.score_et_home, matches.score_et_home),
score_et_away = COALESCE(EXCLUDED.score_et_away, matches.score_et_away),
score_p_home = COALESCE(EXCLUDED.score_p_home, matches.score_p_home),
score_p_away = COALESCE(EXCLUDED.score_p_away, matches.score_p_away)
RETURNING id
`)
totalGoals++
const matchId = (matchRow as { id: number }).id
// Goals (delete + re-insert)
await db.execute(sql`DELETE FROM goals WHERE match_id = ${matchId}`)
for (const [rawGoals, teamId, ogTeamId] of [
[m.goals1 ?? [], t1Id, t2Id],
[m.goals2 ?? [], t2Id, t1Id],
] as [RawGoal[], number, number][]) {
for (const g of rawGoals) {
if (!g.name) continue
const minute = g.minute != null ? parseInt(String(g.minute)) : null
const actualTeamId = g.owngoal ? ogTeamId : teamId
await db.execute(sql`
INSERT INTO goals (match_id, team_id, player_name, minute, minute_offset, is_penalty, is_own_goal)
VALUES (${matchId}, ${actualTeamId}, ${g.name}, ${!minute || isNaN(minute) ? null : minute},
${g.offset ?? 0}, ${g.penalty ?? false}, ${g.owngoal ?? false})
`)
goalCount++
}
}
matchCount++
}
// away team goals (+ home player own goals that benefit away)
for (const g of [...awayGoals, ...awayPenGoals, ...homeOgGoals]) {
await db.execute(sql`
INSERT INTO goals (match_id, team_id, player_name, minute, minute_offset, is_penalty, is_own_goal)
VALUES (${matchId}, ${t2Id}, ${g.name}, ${g.minute}, ${g.offset}, ${g.isPenalty}, ${g.isOwnGoal})
`)
totalGoals++
// Squads
const squadsData = readJson<RawSquad[]>(path.join(yearDir, 'worldcup.squads.json'))
if (squadsData && Array.isArray(squadsData)) {
for (const sq of squadsData) {
const teamId = await upsertTeam(sq.name)
for (const p of sq.players) {
if (!p.name) continue
const dob = p.date_of_birth ? p.date_of_birth.replace(/\s/g, '') : null
await db.execute(sql`
INSERT INTO squads (tournament_year, team_id, player_name, shirt_number, position, date_of_birth)
VALUES (${year}, ${teamId}, ${p.name}, ${p.number ?? null},
${p.pos ?? null}, ${dob})
ON CONFLICT (tournament_year, team_id, shirt_number) DO UPDATE SET
player_name = EXCLUDED.player_name,
position = EXCLUDED.position,
date_of_birth = EXCLUDED.date_of_birth
`)
}
}
}
totalMatches++
console.log(` ${year}: ${matchCount} matches, ${goalCount} goals`)
totalMatches += matchCount
totalGoals += goalCount
}
// 3. Update tournament aggregates
// 3. Group standings (computed from match results)
console.log('Computing group standings...')
await db.execute(sql`
DELETE FROM group_standings WHERE tournament_year < 2026
`)
await db.execute(sql`
INSERT INTO group_standings (tournament_year, group_name, team_id, played, won, drawn, lost,
goals_for, goals_against, goal_diff, pts)
WITH match_results AS (
SELECT tournament_year, group_name, team1_id AS team_id, score_ft_home AS gf, score_ft_away AS ga
FROM matches WHERE tournament_year < 2026 AND group_name IS NOT NULL
AND is_quali_playoff = false AND score_ft_home IS NOT NULL
UNION ALL
SELECT tournament_year, group_name, team2_id, score_ft_away, score_ft_home
FROM matches WHERE tournament_year < 2026 AND group_name IS NOT NULL
AND is_quali_playoff = false AND score_ft_home IS NOT NULL
)
SELECT tournament_year, group_name, team_id,
COUNT(*)::int,
SUM(CASE WHEN gf > ga THEN 1 ELSE 0 END)::int,
SUM(CASE WHEN gf = ga THEN 1 ELSE 0 END)::int,
SUM(CASE WHEN gf < ga THEN 1 ELSE 0 END)::int,
SUM(gf)::int, SUM(ga)::int, SUM(gf - ga)::int,
SUM(CASE WHEN gf > ga THEN 3 WHEN gf = ga THEN 1 ELSE 0 END)::int
FROM match_results
GROUP BY tournament_year, group_name, team_id
ON CONFLICT (tournament_year, group_name, team_id) DO UPDATE SET
played = EXCLUDED.played, won = EXCLUDED.won, drawn = EXCLUDED.drawn,
lost = EXCLUDED.lost, goals_for = EXCLUDED.goals_for,
goals_against = EXCLUDED.goals_against, goal_diff = EXCLUDED.goal_diff,
pts = EXCLUDED.pts
`)
// 4. Tournament aggregates
await db.execute(sql`
UPDATE tournaments t SET
matches_count = (
SELECT COUNT(*)::int FROM matches WHERE tournament_year = t.year AND is_quali_playoff = false
),
total_goals = (
SELECT COUNT(g.id)::int
FROM goals g JOIN matches m ON g.match_id = m.id
WHERE m.tournament_year = t.year AND m.is_quali_playoff = false
),
matches_count = (
SELECT COUNT(*)::int FROM matches WHERE tournament_year = t.year AND is_quali_playoff = false
),
avg_goals_per_game = (
SELECT ROUND(COUNT(g.id)::numeric / NULLIF(COUNT(DISTINCT m.id), 0), 2)
FROM goals g JOIN matches m ON g.match_id = m.id
WHERE m.tournament_year = t.year AND m.is_quali_playoff = false
AND m.score_ft_home IS NOT NULL
)
WHERE t.year < 2026
`)
console.log(`✅ Seed complete: ${totalMatches} matches, ${totalGoals} goals (19302022)`)
console.log(`\n✅ Seed complete: ${totalMatches} matches, ${totalGoals} goals (19302022)`)
await client.end()
}