fde-worldcup-data-worker / scripts /worldcup /sync-worldcup-data.mjs
wihaha's picture
fix: add sync timeout and import progress
247f49f
Raw
History Blame Contribute Delete
28.6 kB
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();