import postgres from 'postgres' import { drizzle } from 'drizzle-orm/postgres-js' import { sql } from 'drizzle-orm' import { TEAM_ISO, getIso } from '../lib/iso-codes' const DATABASE_URL = process.env.DATABASE_URL ?? 'postgres://wc:wc@localhost:5432/worldcup' const BASE = 'https://raw.githubusercontent.com/openfootball/worldcup.json/master' const YEARS = [ 1930, 1934, 1938, 1950, 1954, 1958, 1962, 1966, 1970, 1974, 1978, 1982, 1986, 1990, 1994, 1998, 2002, 2006, 2010, 2014, 2018, 2022, 2026, ] const HOSTS: Record = { 1930: 'Uruguay', 1934: 'Italy', 1938: 'France', 1950: 'Brazil', 1954: 'Switzerland', 1958: 'Sweden', 1962: 'Chile', 1966: 'England', 1970: 'Mexico', 1974: 'Germany', 1978: 'Argentina', 1982: 'Spain', 1986: 'Mexico', 1990: 'Italy', 1994: 'USA', 1998: 'France', 2002: 'South Korea / Japan', 2006: 'Germany', 2010: 'South Africa', 2014: 'Brazil', 2018: 'Russia', 2022: 'Qatar', 2026: 'USA / Canada / Mexico', } const WINNERS: Record = { 1930: { winner: 'Uruguay', runnerUp: 'Argentina', third: 'USA', fourth: 'Yugoslavia' }, 1934: { winner: 'Italy', runnerUp: 'Czechoslovakia', third: 'Germany', fourth: 'Austria' }, 1938: { winner: 'Italy', runnerUp: 'Hungary', third: 'Brazil', fourth: 'Sweden' }, 1950: { winner: 'Uruguay', runnerUp: 'Brazil', third: 'Sweden', fourth: 'Spain' }, 1954: { winner: 'Germany', runnerUp: 'Hungary', third: 'Austria', fourth: 'Uruguay' }, 1958: { winner: 'Brazil', runnerUp: 'Sweden', third: 'France', fourth: 'Germany' }, 1962: { winner: 'Brazil', runnerUp: 'Czechoslovakia', third: 'Chile', fourth: 'Yugoslavia' }, 1966: { winner: 'England', runnerUp: 'Germany', third: 'Portugal', fourth: 'Soviet Union' }, 1970: { winner: 'Brazil', runnerUp: 'Italy', third: 'Germany', fourth: 'Uruguay' }, 1974: { winner: 'Germany', runnerUp: 'Netherlands', third: 'Poland', fourth: 'Brazil' }, 1978: { winner: 'Argentina', runnerUp: 'Netherlands', third: 'Brazil', fourth: 'Italy' }, 1982: { winner: 'Italy', runnerUp: 'Germany', third: 'Poland', fourth: 'France' }, 1986: { winner: 'Argentina', runnerUp: 'Germany', third: 'France', fourth: 'Belgium' }, 1990: { winner: 'Germany', runnerUp: 'Argentina', third: 'Italy', fourth: 'England' }, 1994: { winner: 'Brazil', runnerUp: 'Italy', third: 'Sweden', fourth: 'Bulgaria' }, 1998: { winner: 'France', runnerUp: 'Brazil', third: 'Croatia', fourth: 'Netherlands' }, 2002: { winner: 'Brazil', runnerUp: 'Germany', third: 'Turkey', fourth: 'South Korea' }, 2006: { winner: 'Italy', runnerUp: 'France', third: 'Germany', fourth: 'Portugal' }, 2010: { winner: 'Spain', runnerUp: 'Netherlands', third: 'Germany', fourth: 'Uruguay' }, 2014: { winner: 'Germany', runnerUp: 'Argentina', third: 'Netherlands', fourth: 'Brazil' }, 2018: { winner: 'France', runnerUp: 'Croatia', third: 'Belgium', fourth: 'England' }, 2022: { winner: 'Argentina', runnerUp: 'France', third: 'Croatia', fourth: 'Morocco' }, } async function fetchJson(url: string): Promise { try { const res = await fetch(url) if (!res.ok) return null return res.json() } catch { return null } } type RawGoal = { name: string; minute?: string | number; offset?: number; penalty?: boolean; owngoal?: boolean } type RawScore = { ft?: number[]; ht?: number[]; et?: number[]; p?: number[] } | 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[] } function parseScore(score: RawScore | undefined) { if (!score) return {} if (Array.isArray(score)) return { ft: score } return { ft: score.ft, ht: score.ht, et: score.et, p: score.p } } async function run() { const client = postgres(DATABASE_URL, { max: 5, ...(process.env.DB_PASSWORD ? { password: process.env.DB_PASSWORD } : {}), }) const db = drizzle(client) console.log('Creating tables...') await db.execute(sql` CREATE TABLE IF NOT EXISTS tournaments ( year INTEGER PRIMARY KEY, host TEXT NOT NULL, winner TEXT, runner_up TEXT, third_place TEXT, fourth_place TEXT, teams_count INTEGER, matches_count INTEGER, total_goals INTEGER, avg_goals_per_game NUMERIC(4,2) ); CREATE TABLE IF NOT EXISTS teams ( id SERIAL PRIMARY KEY, name TEXT UNIQUE NOT NULL, iso2 TEXT, fifa_code TEXT, continent TEXT, confederation TEXT ); CREATE TABLE IF NOT EXISTS stadiums ( id SERIAL PRIMARY KEY, tournament_year INTEGER, name TEXT NOT NULL, city TEXT, country_code TEXT, capacity INTEGER, timezone TEXT, coordinates TEXT ); CREATE TABLE IF NOT EXISTS matches ( id SERIAL PRIMARY KEY, tournament_year INTEGER NOT NULL, round TEXT NOT NULL, group_name TEXT, date DATE, time_local TEXT, stadium_id INTEGER, team1_id INTEGER NOT NULL, team2_id INTEGER NOT NULL, score_ft_home INTEGER, score_ft_away INTEGER, score_ht_home INTEGER, score_ht_away INTEGER, score_et_home INTEGER, score_et_away INTEGER, score_p_home INTEGER, score_p_away INTEGER, is_quali_playoff BOOLEAN DEFAULT false ); CREATE UNIQUE INDEX IF NOT EXISTS matches_unique ON matches (tournament_year, team1_id, team2_id, date, is_quali_playoff); CREATE TABLE IF NOT EXISTS goals ( id SERIAL PRIMARY KEY, match_id INTEGER NOT NULL, team_id INTEGER NOT NULL, player_name TEXT NOT NULL, minute INTEGER, minute_offset INTEGER DEFAULT 0, is_penalty BOOLEAN DEFAULT false, is_own_goal BOOLEAN DEFAULT false ); CREATE TABLE IF NOT EXISTS group_standings ( tournament_year INTEGER NOT NULL, group_name TEXT NOT NULL, team_id INTEGER NOT NULL, pos INTEGER, played INTEGER DEFAULT 0, won INTEGER DEFAULT 0, drawn INTEGER DEFAULT 0, lost INTEGER DEFAULT 0, goals_for INTEGER DEFAULT 0, goals_against INTEGER DEFAULT 0, goal_diff INTEGER DEFAULT 0, pts INTEGER DEFAULT 0, PRIMARY KEY (tournament_year, group_name, team_id) ); CREATE TABLE IF NOT EXISTS squads ( id SERIAL PRIMARY KEY, tournament_year INTEGER NOT NULL, team_id INTEGER NOT NULL, player_name TEXT NOT NULL, shirt_number INTEGER, position TEXT, date_of_birth DATE ); CREATE UNIQUE INDEX IF NOT EXISTS squads_unique ON squads (tournament_year, team_id, shirt_number); `) const teamCache = new Map() async function upsertTeam(name: string, extra?: { iso2?: string; fifaCode?: string; continent?: string; confederation?: string }) { if (teamCache.has(name)) return teamCache.get(name)! const iso2 = extra?.iso2 ?? getIso(name) const [row] = await db.execute(sql` INSERT INTO teams (name, iso2, fifa_code, continent, confederation) VALUES (${name}, ${iso2 ?? null}, ${extra?.fifaCode ?? null}, ${extra?.continent ?? null}, ${extra?.confederation ?? null}) ON CONFLICT (name) DO UPDATE SET iso2 = COALESCE(EXCLUDED.iso2, teams.iso2), fifa_code = COALESCE(EXCLUDED.fifa_code, teams.fifa_code), continent = COALESCE(EXCLUDED.continent, teams.continent), confederation = COALESCE(EXCLUDED.confederation, teams.confederation) RETURNING id `) const id = (row as { id: number }).id teamCache.set(name, id) return id } async function upsertMatch( year: number, round: string, group: string | null, dateStr: string | null, timeStr: string | null, team1Id: number, team2Id: number, score: ReturnType, isQuali: boolean ) { const rows = 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}, ${round}, ${group}, ${dateStr ?? null}, ${timeStr ?? null}, ${team1Id}, ${team2Id}, ${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}, ${isQuali} ) ON CONFLICT (tournament_year, team1_id, team2_id, date, is_quali_playoff) DO UPDATE SET round = EXCLUDED.round, 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), time_local = COALESCE(EXCLUDED.time_local, matches.time_local) RETURNING id `) return (rows[0] as { id: number }).id } async function syncGoals(matchId: number, teamId: number, rawGoals: RawGoal[], isOwnGoalTeamId: number) { await db.execute(sql`DELETE FROM goals WHERE match_id = ${matchId}`) for (const g of rawGoals) { if (!g.name) continue const minute = g.minute != null ? parseInt(String(g.minute)) : null const scoringTeamId = g.owngoal ? isOwnGoalTeamId : teamId await db.execute(sql` INSERT INTO goals (match_id, team_id, player_name, minute, minute_offset, is_penalty, is_own_goal) VALUES (${matchId}, ${scoringTeamId}, ${g.name}, ${isNaN(minute!) ? null : minute}, ${g.offset ?? 0}, ${g.penalty ?? false}, ${g.owngoal ?? false}) `) } } for (const year of YEARS) { console.log(`\nSyncing ${year}...`) // 1. Upsert tournament const winData = WINNERS[year] await db.execute(sql` INSERT INTO tournaments (year, host, winner, runner_up, third_place, fourth_place) VALUES (${year}, ${HOSTS[year]}, ${winData?.winner ?? null}, ${winData?.runnerUp ?? null}, ${winData?.third ?? null}, ${winData?.fourth ?? null}) ON CONFLICT (year) DO UPDATE SET winner = COALESCE(EXCLUDED.winner, tournaments.winner), runner_up = COALESCE(EXCLUDED.runner_up, tournaments.runner_up) `) // 2. Teams enrichment const teamsData = await fetchJson(`${BASE}/${year}/worldcup.teams.json`) as Record[] | null if (teamsData && Array.isArray(teamsData)) { for (const t of teamsData) { const name = (t.name ?? t.name_normalised) as string const iso2 = (t.flag_icon as string)?.match(/[\uD83C][\uDDE6-\uDDFF][\uD83C][\uDDE6-\uDDFF]/)?.[0] ? TEAM_ISO[name as string] ?? getIso(name) : TEAM_ISO[name as string] ?? getIso(name) await upsertTeam(name, { iso2: iso2, fifaCode: t.fifa_code as string, continent: t.continent as string, confederation: t.confed as string, }) } } // 3. Stadiums const stadiumsData = await fetchJson(`${BASE}/${year}/worldcup.stadiums.json`) as { stadiums?: Record[] } | null if (stadiumsData?.stadiums) { for (const s of stadiumsData.stadiums) { await db.execute(sql` INSERT INTO stadiums (tournament_year, name, city, country_code, capacity, timezone, coordinates) VALUES (${year}, ${s.name as string}, ${s.city as string}, ${(s.cc as string | undefined) ?? null}, ${(s.capacity as number | undefined) ?? null}, ${(s.timezone as string | undefined) ?? null}, ${(s.coords as string | undefined) ?? null}) ON CONFLICT DO NOTHING `) } } // 4. Main matches const mainData = await fetchJson(`${BASE}/${year}/worldcup.json`) as RawData | null if (!mainData?.matches) { console.log(` No match data`); continue } let matchCount = 0, goalCount = 0 for (const m of mainData.matches) { const t1Id = await upsertTeam(m.team1) const t2Id = await upsertTeam(m.team2) const score = parseScore(m.score) const group = m.group ?? null const matchId = await upsertMatch(year, m.round ?? 'Unknown', group, m.date ?? null, m.time ?? null, t1Id, t2Id, score, false) if (m.goals1?.length) await syncGoals(matchId, t1Id, m.goals1, t2Id) if (m.goals2?.length) await syncGoals(matchId, t2Id, m.goals2, t1Id) matchCount++ goalCount += (m.goals1?.length ?? 0) + (m.goals2?.length ?? 0) } // 5. Standings (2014, 2018) const standingsData = await fetchJson(`${BASE}/${year}/worldcup.standings.json`) as { groups?: Record[] } | null if (standingsData?.groups) { for (const grp of standingsData.groups) { const standings = grp.standings as Record[] for (const s of standings) { const t = s.team as { name: string; code: string } const teamId = await upsertTeam(t.name, { fifaCode: t.code }) await db.execute(sql` INSERT INTO group_standings (tournament_year, group_name, team_id, pos, played, won, drawn, lost, goals_for, goals_against, goal_diff, pts) VALUES (${year}, ${grp.name as string}, ${teamId}, ${s.pos as number ?? null}, ${s.played as number ?? 0}, ${s.won as number ?? 0}, ${s.drawn as number ?? 0}, ${s.lost as number ?? 0}, ${s.goals_for as number ?? 0}, ${s.goals_against as number ?? 0}, ${((s.goals_for as number ?? 0) - (s.goals_against as number ?? 0))}, ${s.pts as number ?? 0}) ON CONFLICT (tournament_year, group_name, team_id) DO UPDATE SET pos = EXCLUDED.pos, 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 `) } } } else if (year !== 2026) { // Compute standings from match results for years without standings.json await db.execute(sql` 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 = ${year} 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 = ${year} AND group_name IS NOT NULL AND is_quali_playoff = false AND score_ft_home IS NOT NULL ) INSERT INTO group_standings (tournament_year, group_name, team_id, played, won, drawn, lost, goals_for, goals_against, goal_diff, pts) 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 `) } // 6. Squads (2026) const squadsData = await fetchJson(`${BASE}/${year}/worldcup.squads.json`) as Record[] | null if (squadsData && Array.isArray(squadsData)) { for (const sq of squadsData) { const teamId = await upsertTeam(sq.name as string) for (const p of (sq.players as Record[])) { await db.execute(sql` INSERT INTO squads (tournament_year, team_id, player_name, shirt_number, position, date_of_birth) VALUES (${year}, ${teamId}, ${p.name as string}, ${p.number as number ?? null}, ${p.pos as string ?? null}, ${p.date_of_birth as string ?? null}) 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 `) } } console.log(` Squads loaded for ${year}`) } // 7. Quali playoffs (2026) const qualiData = await fetchJson(`${BASE}/${year}/worldcup.quali_playoffs.json`) as RawData | null if (qualiData?.matches) { for (const m of qualiData.matches) { const t1Id = await upsertTeam(m.team1) const t2Id = await upsertTeam(m.team2) const score = parseScore(m.score) const matchId = await upsertMatch(year, m.round ?? 'Qualifier', null, m.date ?? null, m.time ?? null, t1Id, t2Id, score, true) if (m.goals1?.length) await syncGoals(matchId, t1Id, m.goals1, t2Id) if (m.goals2?.length) await syncGoals(matchId, t2Id, m.goals2, t1Id) } console.log(` Quali playoffs: ${qualiData.matches.length} matches`) } // 8. Recompute tournament aggregates await db.execute(sql` UPDATE tournaments SET matches_count = (SELECT COUNT(*)::int FROM matches WHERE tournament_year = ${year} AND is_quali_playoff = false), total_goals = (SELECT COALESCE(SUM(score_ft_home + score_ft_away), 0)::int FROM matches WHERE tournament_year = ${year} AND is_quali_playoff = false AND score_ft_home IS NOT NULL), avg_goals_per_game = ( SELECT ROUND(COALESCE(SUM(score_ft_home + score_ft_away), 0)::numeric / NULLIF(COUNT(*), 0), 2) FROM matches WHERE tournament_year = ${year} AND is_quali_playoff = false AND score_ft_home IS NOT NULL ) WHERE year = ${year} `) console.log(` āœ“ ${matchCount} matches, ${goalCount} goals`) } // Compute 2026 group standings from match results await db.execute(sql` 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 ) INSERT INTO group_standings (tournament_year, group_name, team_id, played, won, drawn, lost, goals_for, goals_against, goal_diff, pts) 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 `) console.log('\nāœ… Sync complete!') await client.end() } run().catch(e => { console.error(e); process.exit(1) })