Spaces:
Sleeping
Sleeping
| import { execFile } from 'node:child_process'; | |
| import crypto from 'node:crypto'; | |
| import fs from 'node:fs'; | |
| import { promisify } from 'node:util'; | |
| import { loadLocalEnv } from '../gaokao/lib/env.mjs'; | |
| import { withDb } from '../gaokao/lib/db.mjs'; | |
| loadLocalEnv(); | |
| const execFileAsync = promisify(execFile); | |
| const sourceCache = new Map(); | |
| const IMPORT_FILES = [ | |
| { key: 'sources', table: 'worldcup_sources', file: 'data/worldcup/import/001_sources.csv' }, | |
| { key: 'teams', table: 'worldcup_teams', file: 'data/worldcup/import/002_teams.csv' }, | |
| { key: 'venues', table: 'worldcup_venues', file: 'data/worldcup/import/003_venues.csv' }, | |
| { key: 'matches', table: 'worldcup_matches', file: 'data/worldcup/import/004_matches.csv' }, | |
| { key: 'rankings', table: 'worldcup_team_rankings', file: 'data/worldcup/import/005_team_rankings.csv' }, | |
| { key: 'recent_form', table: 'worldcup_team_form', file: 'data/worldcup/import/006_team_form.csv' }, | |
| { key: 'data_gaps', table: 'worldcup_data_gaps', file: 'data/worldcup/import/008_data_gaps.csv' }, | |
| ]; | |
| const TABLE_ID_RENAMES = { | |
| teams: ['team_id'], | |
| venues: ['venue_id'], | |
| matches: ['match_id'], | |
| rankings: ['ranking_id'], | |
| recent_form: ['form_match_id'], | |
| data_gaps: ['gap_id'], | |
| }; | |
| const INT_FIELDS = new Set([ | |
| 'season', | |
| 'capacity', | |
| 'home_score_90', | |
| 'away_score_90', | |
| 'home_score_extra', | |
| 'away_score_extra', | |
| 'home_penalties', | |
| 'away_penalties', | |
| 'rank', | |
| 'previous_rank', | |
| 'goals_for', | |
| 'goals_against', | |
| ]); | |
| const FLOAT_FIELDS = new Set(['latitude', 'longitude', 'rating', 'opponent_elo']); | |
| const BOOLEAN_FIELDS = new Set(['is_host', 'is_neutral', 'is_home']); | |
| const JSON_FIELDS = new Set(['raw_data']); | |
| const ODDS_MARKET_TITLES = { | |
| h2h: '胜平负', | |
| spreads: '让球', | |
| totals: '大小球', | |
| outrights: '冠军/晋级期货', | |
| }; | |
| const DEFAULT_ODDS_BOOKMAKERS = [ | |
| 'pinnacle', | |
| 'bet365', | |
| 'williamhill', | |
| 'unibet', | |
| 'betfair', | |
| 'matchbook', | |
| 'draftkings', | |
| 'fanduel', | |
| 'betmgm', | |
| 'caesars', | |
| ]; | |
| function parseCsv(text) { | |
| const rows = []; | |
| let row = []; | |
| let cell = ''; | |
| let quoted = false; | |
| for (let index = 0; index < text.length; index += 1) { | |
| const char = text[index]; | |
| const next = text[index + 1]; | |
| if (char === '"' && quoted && next === '"') { | |
| cell += '"'; | |
| index += 1; | |
| continue; | |
| } | |
| if (char === '"') { | |
| quoted = !quoted; | |
| continue; | |
| } | |
| if (char === ',' && !quoted) { | |
| row.push(cell); | |
| cell = ''; | |
| continue; | |
| } | |
| if ((char === '\n' || char === '\r') && !quoted) { | |
| if (char === '\r' && next === '\n') index += 1; | |
| row.push(cell); | |
| if (row.some((value) => value.trim())) rows.push(row); | |
| row = []; | |
| cell = ''; | |
| continue; | |
| } | |
| cell += char; | |
| } | |
| row.push(cell); | |
| if (row.some((value) => value.trim())) rows.push(row); | |
| return rows; | |
| } | |
| function normalizeKey(key) { | |
| return String(key || '').trim().toLowerCase().replace(/\s+/g, '_'); | |
| } | |
| function rowsFromCsv(filePath) { | |
| const parsed = parseCsv(fs.readFileSync(filePath, 'utf8')); | |
| const headers = parsed.shift().map(normalizeKey); | |
| return parsed.map((row) => Object.fromEntries(headers.map((key, index) => [key, row[index]?.trim() || null]))); | |
| } | |
| function normalizeValue(key, value) { | |
| if (value === '' || value === undefined) return null; | |
| if (BOOLEAN_FIELDS.has(key)) { | |
| const text = String(value || '').toLowerCase().replace(/"/g, ''); | |
| return text === 'true' || text === '1'; | |
| } | |
| if (INT_FIELDS.has(key)) return value == null ? null : Number.parseInt(String(value), 10); | |
| if (FLOAT_FIELDS.has(key)) return value == null ? null : Number.parseFloat(String(value)); | |
| if (JSON_FIELDS.has(key)) return value || '{}'; | |
| return value; | |
| } | |
| async function ensureRunTable(pool) { | |
| await pool.query(` | |
| create table if not exists worldcup_ingestion_runs ( | |
| id text primary key, | |
| job_name text not null, | |
| status text not null, | |
| started_at timestamptz not null default now(), | |
| finished_at timestamptz, | |
| source_summary jsonb not null default '{}'::jsonb, | |
| records_fetched jsonb not null default '{}'::jsonb, | |
| records_upserted jsonb not null default '{}'::jsonb, | |
| error_message text, | |
| log_text text, | |
| created_at timestamptz not null default now() | |
| ) | |
| `); | |
| } | |
| async function ensureEnrichmentTables(pool) { | |
| await pool.query(` | |
| create table if not exists worldcup_market_odds_snapshots ( | |
| id text primary key, | |
| match_id text references worldcup_matches(id), | |
| provider_event_id text, | |
| bookmaker_key text, | |
| bookmaker_title text, | |
| market_key text not null, | |
| market_title text, | |
| home_odds double precision, | |
| draw_odds double precision, | |
| away_odds double precision, | |
| handicap_line double precision, | |
| total_line double precision, | |
| last_update timestamptz, | |
| snapshot_time timestamptz not null, | |
| source_id text references worldcup_sources(id), | |
| raw_data jsonb not null default '{}'::jsonb, | |
| created_at timestamptz not null default now() | |
| ); | |
| create table if not exists worldcup_weather_snapshots ( | |
| id text primary key, | |
| match_id text references worldcup_matches(id), | |
| venue_id text references worldcup_venues(id), | |
| forecast_time timestamptz, | |
| snapshot_time timestamptz not null, | |
| temperature_c double precision, | |
| apparent_temperature_c double precision, | |
| humidity_pct double precision, | |
| precipitation_probability_pct double precision, | |
| precipitation_mm double precision, | |
| wind_speed_kmh double precision, | |
| wind_gusts_kmh double precision, | |
| weather_code integer, | |
| source_id text references worldcup_sources(id), | |
| raw_data jsonb not null default '{}'::jsonb, | |
| created_at timestamptz not null default now() | |
| ); | |
| `); | |
| } | |
| async function startRun(pool) { | |
| await pool.query(` | |
| update worldcup_ingestion_runs | |
| set status = 'failed', | |
| finished_at = now(), | |
| error_message = coalesce(error_message, 'stale running job exceeded timeout'), | |
| log_text = coalesce(log_text, '') || E'\n[daemon]\nstale running job marked failed before new run' | |
| where status = 'running' | |
| and started_at < now() - interval '90 minutes' | |
| `); | |
| const id = `worldcup-sync-${new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14)}-${Math.random().toString(36).slice(2, 8)}`; | |
| await pool.query( | |
| `insert into worldcup_ingestion_runs (id, job_name, status) values ($1, $2, $3)`, | |
| [id, 'worldcup-data-sync', 'running'], | |
| ); | |
| return id; | |
| } | |
| async function finishRun(pool, runId, status, payload) { | |
| await pool.query( | |
| `update worldcup_ingestion_runs | |
| set status = $2, | |
| finished_at = now(), | |
| source_summary = $3::jsonb, | |
| records_fetched = $4::jsonb, | |
| records_upserted = $5::jsonb, | |
| error_message = $6, | |
| log_text = $7 | |
| where id = $1`, | |
| [ | |
| runId, | |
| status, | |
| JSON.stringify(payload.sourceSummary || {}), | |
| JSON.stringify(payload.recordsFetched || {}), | |
| JSON.stringify(payload.recordsUpserted || {}), | |
| payload.errorMessage || null, | |
| payload.logText || '', | |
| ], | |
| ); | |
| } | |
| async function ensureSource(pool, rowObj) { | |
| const sourceName = rowObj.source_name; | |
| if (!sourceName) return null; | |
| const sourceUrl = rowObj.source_url || null; | |
| const fetchedAt = rowObj.fetched_at || null; | |
| const cacheKey = `${sourceName}::${sourceUrl || ''}`; | |
| if (sourceCache.has(cacheKey)) return sourceCache.get(cacheKey); | |
| const existing = await pool.query( | |
| `select id from worldcup_sources where source_name = $1 and coalesce(source_url, '') = coalesce($2, '') limit 1`, | |
| [sourceName, sourceUrl], | |
| ); | |
| if (existing.rows[0]?.id) { | |
| sourceCache.set(cacheKey, existing.rows[0].id); | |
| return existing.rows[0].id; | |
| } | |
| const sourceId = `auto-${sourceName}`.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); | |
| const result = await pool.query( | |
| `insert into worldcup_sources (id, source_type, source_name, source_url, fetched_at, raw_data) | |
| values ($1, 'auto', $2, $3, $4, '{}'::jsonb) | |
| on conflict (id) do update set | |
| source_name = excluded.source_name, | |
| source_url = excluded.source_url, | |
| fetched_at = excluded.fetched_at | |
| returning id`, | |
| [sourceId, sourceName, sourceUrl, fetchedAt], | |
| ); | |
| sourceCache.set(cacheKey, result.rows[0].id); | |
| return result.rows[0].id; | |
| } | |
| async function ensureNamedSource(pool, source) { | |
| return ensureSource(pool, { | |
| source_name: source.source_name, | |
| source_url: source.source_url, | |
| fetched_at: source.fetched_at || new Date().toISOString(), | |
| }); | |
| } | |
| function slug(value) { | |
| return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); | |
| } | |
| function stableHash(value) { | |
| return crypto.createHash('sha1').update(JSON.stringify(value)).digest('hex').slice(0, 12); | |
| } | |
| function normalizeTeamName(value) { | |
| return String(value || '') | |
| .toLowerCase() | |
| .normalize('NFD') | |
| .replace(/[\u0300-\u036f]/g, '') | |
| .replace(/&/g, 'and') | |
| .replace(/\b(united states|usa|u\.s\.a\.)\b/g, 'usa') | |
| .replace(/\bdr congo\b/g, 'congo dr') | |
| .replace(/\bcape verde\b/g, 'cabo verde') | |
| .replace(/\bczech republic\b/g, 'czechia') | |
| .replace(/\s+/g, ' ') | |
| .trim(); | |
| } | |
| function buildTeamNameSet(teamId, nameEn) { | |
| const values = new Set([ | |
| normalizeTeamName(teamId.replace(/-/g, ' ')), | |
| normalizeTeamName(nameEn), | |
| ]); | |
| const aliases = { | |
| usa: ['united states', 'usa'], | |
| 'south-korea': ['south korea', 'korea republic'], | |
| 'dr-congo': ['dr congo', 'congo dr', 'congo democratic republic'], | |
| 'cape-verde': ['cape verde', 'cabo verde'], | |
| 'czech-republic': ['czech republic', 'czechia'], | |
| 'ivory-coast': ['ivory coast', "cote d'ivoire"], | |
| 'cura-ao': ['curacao', 'curaçao'], | |
| turkey: ['turkey', 'turkiye'], | |
| }; | |
| for (const alias of aliases[teamId] || []) values.add(normalizeTeamName(alias)); | |
| return values; | |
| } | |
| function closestHourlyIndex(times, kickoffIso) { | |
| if (!times?.length || !kickoffIso) return -1; | |
| const target = new Date(kickoffIso).getTime(); | |
| let bestIndex = -1; | |
| let bestDistance = Number.POSITIVE_INFINITY; | |
| for (let index = 0; index < times.length; index += 1) { | |
| const time = new Date(times[index]).getTime(); | |
| const distance = Math.abs(time - target); | |
| if (distance < bestDistance) { | |
| bestDistance = distance; | |
| bestIndex = index; | |
| } | |
| } | |
| return bestIndex; | |
| } | |
| async function fetchJson(url, options = {}) { | |
| const timeoutMs = options.timeoutMs || Number(process.env.WORLDCUP_FETCH_TIMEOUT_MS || 15000); | |
| const fetchOptions = { ...options }; | |
| delete fetchOptions.timeoutMs; | |
| if (!fetchOptions.signal) { | |
| fetchOptions.signal = AbortSignal.timeout(timeoutMs); | |
| } | |
| let response; | |
| try { | |
| response = await fetch(url, fetchOptions); | |
| } catch (error) { | |
| const cause = error.cause ? ` cause=${error.cause.code || error.cause.message || String(error.cause)}` : ''; | |
| throw new Error(`Fetch failed for ${url}: ${error.message || String(error)}${cause}`); | |
| } | |
| if (!response.ok) { | |
| const text = await response.text().catch(() => ''); | |
| throw new Error(`HTTP ${response.status} ${response.statusText} for ${url}: ${text.slice(0, 300)}`); | |
| } | |
| return response.json(); | |
| } | |
| function serializeError(error) { | |
| return { | |
| message: error.message || String(error), | |
| cause: error.cause ? (error.cause.code || error.cause.message || String(error.cause)) : null, | |
| }; | |
| } | |
| async function fetchWeatherSnapshots(pool) { | |
| const sourceId = await ensureNamedSource(pool, { | |
| source_name: 'open-meteo', | |
| source_url: 'https://open-meteo.com/', | |
| }); | |
| const matches = await pool.query(` | |
| select | |
| m.id, | |
| m.kickoff_utc, | |
| m.venue_id, | |
| v.latitude, | |
| v.longitude, | |
| v.timezone | |
| from worldcup_matches m | |
| join worldcup_venues v on v.id = m.venue_id | |
| where m.status in ('scheduled', 'active') | |
| and m.kickoff_utc is not null | |
| and v.latitude is not null | |
| and v.longitude is not null | |
| and m.kickoff_utc between now() - interval '2 hours' and now() + interval '16 days' | |
| `); | |
| let inserted = 0; | |
| let updated = 0; | |
| const snapshotTime = new Date().toISOString(); | |
| console.log(`[weather] candidates=${matches.rows.length}`); | |
| for (const match of matches.rows) { | |
| const params = new URLSearchParams({ | |
| latitude: String(match.latitude), | |
| longitude: String(match.longitude), | |
| hourly: [ | |
| 'temperature_2m', | |
| 'apparent_temperature', | |
| 'relative_humidity_2m', | |
| 'precipitation_probability', | |
| 'precipitation', | |
| 'wind_speed_10m', | |
| 'wind_gusts_10m', | |
| 'weather_code', | |
| ].join(','), | |
| timezone: 'UTC', | |
| forecast_days: '16', | |
| }); | |
| const data = await fetchJson(`https://api.open-meteo.com/v1/forecast?${params.toString()}`, { | |
| timeoutMs: Number(process.env.WEATHER_FETCH_TIMEOUT_MS || 8000), | |
| }); | |
| const index = closestHourlyIndex(data.hourly?.time, match.kickoff_utc); | |
| if (index < 0) continue; | |
| const row = { | |
| id: `weather-${match.id}-${slug(snapshotTime.slice(0, 13))}`, | |
| match_id: match.id, | |
| venue_id: match.venue_id, | |
| forecast_time: data.hourly.time[index], | |
| snapshot_time: snapshotTime, | |
| temperature_c: data.hourly.temperature_2m?.[index] ?? null, | |
| apparent_temperature_c: data.hourly.apparent_temperature?.[index] ?? null, | |
| humidity_pct: data.hourly.relative_humidity_2m?.[index] ?? null, | |
| precipitation_probability_pct: data.hourly.precipitation_probability?.[index] ?? null, | |
| precipitation_mm: data.hourly.precipitation?.[index] ?? null, | |
| wind_speed_kmh: data.hourly.wind_speed_10m?.[index] ?? null, | |
| wind_gusts_kmh: data.hourly.wind_gusts_10m?.[index] ?? null, | |
| weather_code: data.hourly.weather_code?.[index] ?? null, | |
| source_id: sourceId, | |
| raw_data: JSON.stringify({ match, units: data.hourly_units, selected_index: index }), | |
| }; | |
| const result = await pool.query( | |
| `insert into worldcup_weather_snapshots ( | |
| id, match_id, venue_id, forecast_time, snapshot_time, | |
| temperature_c, apparent_temperature_c, humidity_pct, | |
| precipitation_probability_pct, precipitation_mm, wind_speed_kmh, | |
| wind_gusts_kmh, weather_code, source_id, raw_data | |
| ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15::jsonb) | |
| on conflict (id) do update set | |
| forecast_time = excluded.forecast_time, | |
| snapshot_time = excluded.snapshot_time, | |
| temperature_c = excluded.temperature_c, | |
| apparent_temperature_c = excluded.apparent_temperature_c, | |
| humidity_pct = excluded.humidity_pct, | |
| precipitation_probability_pct = excluded.precipitation_probability_pct, | |
| precipitation_mm = excluded.precipitation_mm, | |
| wind_speed_kmh = excluded.wind_speed_kmh, | |
| wind_gusts_kmh = excluded.wind_gusts_kmh, | |
| weather_code = excluded.weather_code, | |
| raw_data = excluded.raw_data | |
| returning (xmax = 0) as inserted`, | |
| Object.values(row), | |
| ); | |
| if (result.rows[0]?.inserted) inserted += 1; | |
| else updated += 1; | |
| } | |
| return { fetched: matches.rows.length, inserted, updated }; | |
| } | |
| function extractH2hOdds(event, bookmaker, match) { | |
| const market = bookmaker.markets?.find((item) => item.key === 'h2h'); | |
| if (!market) return null; | |
| let homeOdds = null; | |
| let drawOdds = null; | |
| let awayOdds = null; | |
| const homeNames = buildTeamNameSet(match.home_team_id, match.home_name_en); | |
| const awayNames = buildTeamNameSet(match.away_team_id, match.away_name_en); | |
| for (const outcome of market.outcomes || []) { | |
| const name = normalizeTeamName(outcome.name); | |
| if (name === 'draw') drawOdds = outcome.price; | |
| else if (homeNames.has(name)) homeOdds = outcome.price; | |
| else if (awayNames.has(name)) awayOdds = outcome.price; | |
| } | |
| if (!homeOdds && !drawOdds && !awayOdds) return null; | |
| return { | |
| market, | |
| homeOdds, | |
| drawOdds, | |
| awayOdds, | |
| }; | |
| } | |
| async function fetchOddsSnapshots(pool) { | |
| const apiKey = process.env.ODDS_API || process.env.THE_ODDS_API_KEY || process.env.ODDS_API_KEY; | |
| if (!apiKey) { | |
| console.log('[odds] ODDS_API is not configured; skipping odds sync'); | |
| return { fetched: 0, inserted: 0, updated: 0, skipped: true }; | |
| } | |
| const sourceId = await ensureNamedSource(pool, { | |
| source_name: 'the-odds-api', | |
| source_url: 'https://the-odds-api.com/', | |
| }); | |
| const sportKeys = (process.env.THE_ODDS_SPORT_KEYS || 'soccer_fifa_world_cup') | |
| .split(',') | |
| .map((item) => item.trim()) | |
| .filter(Boolean); | |
| const bookmakers = (process.env.THE_ODDS_BOOKMAKERS || DEFAULT_ODDS_BOOKMAKERS.join(',')) | |
| .split(',') | |
| .map((item) => item.trim()) | |
| .filter(Boolean) | |
| .join(','); | |
| const markets = (process.env.THE_ODDS_MARKETS || 'h2h') | |
| .split(',') | |
| .map((item) => item.trim()) | |
| .filter(Boolean) | |
| .join(','); | |
| const matches = await pool.query(` | |
| select | |
| m.id, | |
| m.kickoff_utc, | |
| m.home_team_id, | |
| m.away_team_id, | |
| ht.name_en as home_name_en, | |
| at.name_en as away_name_en | |
| from worldcup_matches m | |
| join worldcup_teams ht on ht.id = m.home_team_id | |
| join worldcup_teams at on at.id = m.away_team_id | |
| where m.status in ('scheduled', 'active') | |
| and m.kickoff_utc is not null | |
| and m.kickoff_utc >= now() - interval '4 hours' | |
| `); | |
| const matchIndex = new Map(); | |
| for (const match of matches.rows) { | |
| const homeNames = buildTeamNameSet(match.home_team_id, match.home_name_en); | |
| const awayNames = buildTeamNameSet(match.away_team_id, match.away_name_en); | |
| for (const home of homeNames) { | |
| for (const away of awayNames) { | |
| matchIndex.set(`${home}|${away}`, match); | |
| matchIndex.set(`${away}|${home}`, match); | |
| } | |
| } | |
| } | |
| let fetched = 0; | |
| let inserted = 0; | |
| let updated = 0; | |
| const snapshotTime = new Date().toISOString(); | |
| for (const sportKey of sportKeys) { | |
| const params = new URLSearchParams({ | |
| apiKey, | |
| regions: process.env.THE_ODDS_REGIONS || 'us,uk,eu', | |
| markets, | |
| oddsFormat: 'decimal', | |
| dateFormat: 'iso', | |
| bookmakers, | |
| }); | |
| const events = await fetchJson(`https://api.the-odds-api.com/v4/sports/${sportKey}/odds/?${params.toString()}`, { | |
| timeoutMs: Number(process.env.ODDS_FETCH_TIMEOUT_MS || 20000), | |
| }); | |
| fetched += events.length; | |
| for (const event of events) { | |
| const key = `${normalizeTeamName(event.home_team)}|${normalizeTeamName(event.away_team)}`; | |
| const match = matchIndex.get(key); | |
| if (!match) continue; | |
| for (const bookmaker of event.bookmakers || []) { | |
| const h2h = extractH2hOdds(event, bookmaker, match); | |
| if (!h2h) continue; | |
| const row = { | |
| id: `odds-${match.id}-${slug(bookmaker.key)}-h2h-${stableHash([bookmaker.last_update, h2h.homeOdds, h2h.drawOdds, h2h.awayOdds])}`, | |
| match_id: match.id, | |
| provider_event_id: event.id, | |
| bookmaker_key: bookmaker.key, | |
| bookmaker_title: bookmaker.title, | |
| market_key: 'h2h', | |
| market_title: ODDS_MARKET_TITLES.h2h, | |
| home_odds: h2h.homeOdds, | |
| draw_odds: h2h.drawOdds, | |
| away_odds: h2h.awayOdds, | |
| handicap_line: null, | |
| total_line: null, | |
| last_update: bookmaker.last_update || event.commence_time || null, | |
| snapshot_time: snapshotTime, | |
| source_id: sourceId, | |
| raw_data: JSON.stringify({ sportKey, event, bookmaker }), | |
| }; | |
| const result = await pool.query( | |
| `insert into worldcup_market_odds_snapshots ( | |
| id, match_id, provider_event_id, bookmaker_key, bookmaker_title, | |
| market_key, market_title, home_odds, draw_odds, away_odds, | |
| handicap_line, total_line, last_update, snapshot_time, source_id, raw_data | |
| ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16::jsonb) | |
| on conflict (id) do update set | |
| provider_event_id = excluded.provider_event_id, | |
| bookmaker_title = excluded.bookmaker_title, | |
| home_odds = excluded.home_odds, | |
| draw_odds = excluded.draw_odds, | |
| away_odds = excluded.away_odds, | |
| last_update = excluded.last_update, | |
| snapshot_time = excluded.snapshot_time, | |
| raw_data = excluded.raw_data | |
| returning (xmax = 0) as inserted`, | |
| Object.values(row), | |
| ); | |
| if (result.rows[0]?.inserted) inserted += 1; | |
| else updated += 1; | |
| } | |
| } | |
| } | |
| return { fetched, inserted, updated }; | |
| } | |
| function normalizeRow(key, row) { | |
| const rowObj = { ...row }; | |
| for (const candidate of TABLE_ID_RENAMES[key] || []) { | |
| if (candidate in rowObj) { | |
| rowObj.id = rowObj[candidate]; | |
| delete rowObj[candidate]; | |
| break; | |
| } | |
| } | |
| for (const [field, value] of Object.entries(rowObj)) { | |
| rowObj[field] = normalizeValue(field, value); | |
| } | |
| return rowObj; | |
| } | |
| function buildUpsertQuery(tableKey, tableName, columns) { | |
| const placeholders = columns.map((_, index) => `$${index + 1}`).join(', '); | |
| if (tableKey === 'data_gaps') { | |
| const updates = columns | |
| .filter((column) => !['id', 'created_at'].includes(column)) | |
| .map((column) => { | |
| if (column === 'status') { | |
| return `${column} = case when ${tableName}.status = 'resolved' then ${tableName}.status else excluded.${column} end`; | |
| } | |
| if (column === 'resolved_at' || column === 'resolved_by') { | |
| return `${column} = coalesce(${tableName}.${column}, excluded.${column})`; | |
| } | |
| return `${column} = excluded.${column}`; | |
| }); | |
| return `insert into ${tableName} (${columns.join(', ')}) values (${placeholders}) | |
| on conflict (id) do update set ${updates.join(', ')} | |
| returning (xmax = 0) as inserted`; | |
| } | |
| const updates = columns | |
| .filter((column) => !['id', 'created_at'].includes(column)) | |
| .map((column) => `${column} = excluded.${column}`); | |
| if (columns.includes('updated_at')) { | |
| updates.push('updated_at = now()'); | |
| } | |
| return `insert into ${tableName} (${columns.join(', ')}) values (${placeholders}) | |
| on conflict (id) do update set ${updates.join(', ')} | |
| returning (xmax = 0) as inserted`; | |
| } | |
| async function upsertRows(pool, item) { | |
| const rows = rowsFromCsv(item.file); | |
| let inserted = 0; | |
| let updated = 0; | |
| let processed = 0; | |
| for (const row of rows) { | |
| processed += 1; | |
| const rowObj = normalizeRow(item.key, row); | |
| let finalRow = { ...rowObj }; | |
| if (item.key !== 'sources') { | |
| const sourceId = await ensureSource(pool, rowObj); | |
| finalRow = Object.fromEntries( | |
| Object.entries(finalRow).filter(([key]) => !['source_name', 'source_url', 'fetched_at'].includes(key)), | |
| ); | |
| if (sourceId) finalRow.source_id = sourceId; | |
| } | |
| if (item.key === 'sources') { | |
| finalRow = { | |
| id: finalRow.id, | |
| source_type: finalRow.source_type || 'auto', | |
| source_name: finalRow.source_name, | |
| source_url: finalRow.source_url, | |
| publisher: finalRow.publisher, | |
| fetched_at: finalRow.fetched_at, | |
| raw_data: finalRow.raw_data || '{}', | |
| }; | |
| } | |
| const columns = Object.keys(finalRow); | |
| if (!finalRow.id) { | |
| throw new Error(`Missing id for ${item.key}: ${JSON.stringify(rowObj)}`); | |
| } | |
| const result = await pool.query(buildUpsertQuery(item.key, item.table, columns), Object.values(finalRow)); | |
| if (result.rows[0]?.inserted) inserted += 1; | |
| else updated += 1; | |
| if (rows.length >= 200 && processed % 100 === 0) { | |
| console.log(`[${item.key}] progress=${processed}/${rows.length}`); | |
| } | |
| } | |
| return { fetched: rows.length, inserted, updated }; | |
| } | |
| async function main() { | |
| let runId = ''; | |
| const payload = { | |
| sourceSummary: { | |
| openfootball: 'https://github.com/openfootball/worldcup.json', | |
| fifaRankings: 'https://inside.fifa.com/fifa-world-ranking/men', | |
| internationalResults: 'https://github.com/martj42/international_results', | |
| weather: 'https://open-meteo.com/', | |
| odds: 'https://the-odds-api.com/', | |
| }, | |
| recordsFetched: {}, | |
| recordsUpserted: {}, | |
| logText: '', | |
| }; | |
| await withDb(async (pool) => { | |
| await ensureRunTable(pool); | |
| await ensureEnrichmentTables(pool); | |
| runId = await startRun(pool); | |
| }); | |
| try { | |
| const fetchResult = await execFileAsync(process.execPath, ['scripts/worldcup/fetch_real_worldcup_data.mjs'], { | |
| cwd: process.cwd(), | |
| maxBuffer: 1024 * 1024 * 20, | |
| timeout: Number(process.env.WORLDCUP_FETCHER_TIMEOUT_MS || 180000), | |
| killSignal: 'SIGTERM', | |
| }); | |
| payload.logText += fetchResult.stdout || ''; | |
| if (fetchResult.stderr) payload.logText += `\n[stderr]\n${fetchResult.stderr}`; | |
| await withDb(async (pool) => { | |
| for (const item of IMPORT_FILES) { | |
| if (!fs.existsSync(item.file)) { | |
| throw new Error(`Missing import file: ${item.file}`); | |
| } | |
| const result = await upsertRows(pool, item); | |
| payload.recordsFetched[item.key] = result.fetched; | |
| payload.recordsUpserted[item.key] = { | |
| inserted: result.inserted, | |
| updated: result.updated, | |
| }; | |
| console.log(`[${item.key}] fetched=${result.fetched} inserted=${result.inserted} updated=${result.updated}`); | |
| } | |
| try { | |
| const weatherResult = await fetchWeatherSnapshots(pool); | |
| payload.recordsFetched.weather = weatherResult.fetched; | |
| payload.recordsUpserted.weather = { | |
| inserted: weatherResult.inserted, | |
| updated: weatherResult.updated, | |
| }; | |
| console.log(`[weather] fetched=${weatherResult.fetched} inserted=${weatherResult.inserted} updated=${weatherResult.updated}`); | |
| } catch (error) { | |
| const serialized = serializeError(error); | |
| payload.recordsFetched.weather = 0; | |
| payload.recordsUpserted.weather = { inserted: 0, updated: 0, error: serialized.message, cause: serialized.cause }; | |
| payload.logText += `\n[weather warning]\n${error.stack || serialized.message}`; | |
| console.warn(`[weather] skipped error=${serialized.message}`); | |
| } | |
| try { | |
| const oddsResult = await fetchOddsSnapshots(pool); | |
| payload.recordsFetched.odds = oddsResult.fetched; | |
| payload.recordsUpserted.odds = { | |
| inserted: oddsResult.inserted, | |
| updated: oddsResult.updated, | |
| skipped: oddsResult.skipped || false, | |
| }; | |
| console.log(`[odds] fetched=${oddsResult.fetched} inserted=${oddsResult.inserted} updated=${oddsResult.updated}${oddsResult.skipped ? ' skipped=true' : ''}`); | |
| } catch (error) { | |
| const serialized = serializeError(error); | |
| payload.recordsFetched.odds = 0; | |
| payload.recordsUpserted.odds = { inserted: 0, updated: 0, error: serialized.message, cause: serialized.cause }; | |
| payload.logText += `\n[odds warning]\n${error.stack || serialized.message}`; | |
| console.warn(`[odds] skipped error=${serialized.message}`); | |
| } | |
| try { | |
| const scoreResult = await execFileAsync(process.execPath, ['scripts/worldcup/generate-score-analyses.mjs'], { | |
| cwd: process.cwd(), | |
| maxBuffer: 1024 * 1024 * 20, | |
| timeout: Number(process.env.WORLDCUP_SCORE_ANALYSIS_TIMEOUT_MS || 300000), | |
| killSignal: 'SIGTERM', | |
| }); | |
| payload.recordsFetched.score_analysis = null; | |
| payload.recordsUpserted.score_analysis = { status: 'completed' }; | |
| payload.logText += `\n[score analysis]\n${scoreResult.stdout || ''}`; | |
| if (scoreResult.stdout) process.stdout.write(scoreResult.stdout); | |
| if (scoreResult.stderr) process.stderr.write(scoreResult.stderr); | |
| } catch (error) { | |
| const serialized = serializeError(error); | |
| payload.recordsUpserted.score_analysis = { status: 'skipped', error: serialized.message, cause: serialized.cause }; | |
| payload.logText += `\n[score analysis warning]\n${error.stack || serialized.message}`; | |
| console.warn(`[score-analysis] skipped error=${serialized.message}`); | |
| } | |
| await finishRun(pool, runId, 'success', payload); | |
| }); | |
| console.log(`World Cup sync complete. run_id=${runId}`); | |
| } catch (error) { | |
| payload.errorMessage = error.message || String(error); | |
| payload.logText += `\n[error]\n${error.stack || error.message || String(error)}`; | |
| await withDb(async (pool) => { | |
| await finishRun(pool, runId, 'failed', payload); | |
| }); | |
| console.error(`World Cup sync failed. run_id=${runId}`); | |
| console.error(error); | |
| process.exit(1); | |
| } | |
| } | |
| await main(); | |