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 }) 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 TEAM_ALIASES: Record = { 'West Germany': 'Germany', } const teamCache = new Map() async function upsertTeam(rawName: string, extra?: { iso2?: string; fifaCode?: string; continent?: string; confederation?: string }) { const name = TEAM_ALIASES[rawName] ?? rawName 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) { 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 || m.goals2?.length) { await db.execute(sql`DELETE FROM goals WHERE match_id = ${matchId}`) 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) })