diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1b96d8182..6cf6a809e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,7 +2,28 @@ version: 2 updates: - package-ecosystem: npm - directory: "/" + directory: "/scraper" + schedule: + interval: daily + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] + commit-message: + prefix: "npm" + include: "scope" + groups: + dependencies-patch-and-minor: + update-types: + - minor + - patch + open-pull-requests-limit: 50 + reviewers: + - fabrice404 + assignees: + - fabrice404 + +- package-ecosystem: npm + directory: "/ui" schedule: interval: daily ignore: diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..8a78ee42c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,16 @@ +{ + "cSpell.words": [ + "Cortina", + + "NOC", // National Olympic Committee + "NOCs", // National Olympic Committees + + // color palette + "azzurro", + "giallo", + "rosa", + "rosso", + "verde", + "viola" + ] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..7269c8dc1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,3 @@ +Copyright (c) 2025 Fabrice Lamant. All rights reserved. + +No part of this work may be copied, reproduced, distributed, transmitted, displayed, performed, or otherwise used, in whole or in part, by any means or in any form, without the prior written permission of the copyright holder. diff --git a/scraper/cache.ts b/scraper/cache.ts index fbd397751..aa61c4eb9 100644 --- a/scraper/cache.ts +++ b/scraper/cache.ts @@ -4,7 +4,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs" const debug = Debug(`olympics-calendar:cache`); const cachePath = (key: string): string => { - return `../cache/${key}`; + return `../cache/${key}.cached`; } export const get = (key: string): string | null => { diff --git a/scraper/ics.ts b/scraper/ics.ts new file mode 100644 index 000000000..f00fd8a30 --- /dev/null +++ b/scraper/ics.ts @@ -0,0 +1,150 @@ +// BEGIN:VCALENDAR +// VERSION:2.0 +// PRODID:-//fabrice404//olympics-calendar//archery/AUS//EN +// X-WR-CALNAME:๐ฆ๐บ Australia Archery | Paris 2024 +// NAME:๐ฆ๐บ Australia Archery | Paris 2024 +// BEGIN:VEVENT +// UID:20240725T073000Z-archery-WOMENS-INDIVIDUAL-RANKING-ROUND +// DTSTAMP:20240725T073000Z +// DTSTART:20240725T073000Z +// DTEND:20240725T103000Z +// DESCRIPTION:Archery - Women's Individual Ranking Round\n๐จ๐ณ AN +// Qixuan\n๐ฒ๐ฝ Alejandra VALENCIA\n๐ฒ๐ฉ Alexandra MIRCA\n๐ต๐ท Alondra +// RIVERA\n๐ซ๐ท Amelie CORDEAU\n๐ง๐ท Ana Luiza SLIACHTICAS CAETANO\n๐จ๐ด Ana +// RENDON MARTINEZ\n๐ฒ๐ฝ Ana VAZQUEZ\n๐ฒ๐ฝ Angela RUIZ\n๐ฎ๐ณ Ankita +// BHAKAT\n๐ฒ๐พ Ariana Nur Dania MOHAMAD ZAIRI\n๐ฎ๐ณ Bhajan KAUR\n๐ฌ๐ง +// Bryony PITMAN\n๐น๐ผ CHIU Yi-Ching\n๐ซ๐ท Caroline LOPEZ\n๐บ๐ธ Casey +// KAUFHOLD\n๐บ๐ธ Catalina GNORIEGA\n๐ฉ๐ช Charline SCHWARZ\n๐ฎ๐น Chiara +// REBAGLIATI\n๐ฎ๐ณ Deepika KUMARI\n๐ธ๐ฐ Denisa BARANKOVA\n๐ฎ๐ฉ Diananda +// CHOIRUNISA\n๐ช๐ธ Elia CANALES\n๐น๐ท Elif Berra GOKKIR\n๐ฆ๐น Elisabeth +// STRAKA\n๐ฌ๐ณ Fatoumata SYLLA\n๐ณ๐ฑ Gaby SCHLOESSER\n๐ธ๐ฒ Giorgia +// CESARINI\n๐ฐ๐ท JEON Hunyoung\n๐ช๐ฌ Jana ALI\n๐บ๐ธ Jennifer MUCINO\n๐ฉ๐ช +// Katharina BAUER\n๐ฉ๐ฐ Kirstine DANSTRUP ANDERSEN\n๐น๐ผ LEI +// Chien-Ying\n๐จ๐ณ LI Jiaman\n๐น๐ผ LI Tsai-Chi\n๐ฐ๐ท LIM Sihyeon\n๐ฆ๐บ +// Laura PAEGLIS\n๐ณ๐ฑ Laura van der WINKEL\n๐ซ๐ท Lisa BARBELIN\n๐ท๐ด +// Madalina AMAISTROAIE\n๐จ๐ฟ Marie HORACKOVA\n๐ฌ๐ง Megs HAVERS\n๐ฉ๐ช +// Michelle KROPPEN\n๐ฎ๐ฑ Mikaella MOSHE\n๐ฎ๐ท Mobina FALLAH\n๐ฐ๐ท NAM +// Suhyeon\n๐ฏ๐ต NODA Satsuki\n๐ฒ๐พ Nurul Azreena MOHAMAD FAZIL\n๐ฌ๐ง Penny +// HEALEY\n๐ณ๐ฑ Quinty ROEFFEN\n๐ช๐ช Reena PARNAT\n๐ฎ๐ฉ Rezza OCTAVIA\n๐น๐ณ +// Rihab ELWALID\n๐ฒ๐พ Syaqiera MASHAYIKH\n๐ฎ๐ฉ Syifa Nurafifah KAMAL\n๐ป๐ณ +// Thi Anh Nguyet DO\n๐บ๐ฆ Veronika MARCHENKO\n๐จ๐ฆ Virginie CHENIER\n๐ต๐ฑ +// Wioleta MYSZOR\n๐จ๐ณ YANG Xiaolei\n๐ฆ๐ฟ Yaylagul RAMAZANOVA\n๐ธ๐ฎ Zana +// PINTARIC\n๐บ๐ฟ Ziyodakhon ABDUSATTOROVA +// SUMMARY:๐น Women's Individual Ranking Round +// LOCATION:Invalides +// END:VEVENT + +import { mkdirSync, writeFileSync } from "fs"; +import { Calendar } from "./types"; +import { getFlag } from "./nocs"; + + +// BEGIN:VCALENDAR +// VERSION:2.0 +// PRODID:-//fabrice404//olympics-calendar//3x3-basketball/AUS//EN +// X-WR-CALNAME:๐ฆ๐บ Australia 3x3 Basketball | Paris 2024 +// NAME:๐ฆ๐บ Australia 3x3 Basketball | Paris 2024 +// BEGIN:VEVENT +// UID:20240730T160000Z-3x3-basketball-WOMENS-POOL-ROUND-AUS-CAN +// DTSTAMP:20240730T160000Z +// DTSTART:20240730T160000Z +// DTEND:20240730T162500Z +// DESCRIPTION:3x3 Basketball - Women's Pool Round +// SUMMARY:๐ AUS ๐ฆ๐บ - ๐จ๐ฆ CAN +// LOCATION:La Concorde 1 +// END:VEVENT + +// END:VCALENDAR + +export const generateICSFiles = (calendar: Calendar): void => { + + generateICSFile(calendar, null, null); + + calendar.sports.forEach((sport) => { + generateICSFile(calendar, sport.key, null); + calendar.nocs.forEach((noc) => { + generateICSFile(calendar, sport.key, noc.key); + }); + }); + + calendar.nocs.forEach((noc) => { + generateICSFile(calendar, null, noc.key); + }); +}; + + +export const generateICSFile = (calendar: Calendar, sportKey: string | null, nocKey: string | null): void => { + calendar.languages.forEach((lang) => { + const pathSportKey = sportKey ? sportKey : "all-sports"; + const pathNocKey = nocKey ? nocKey : "calendar" + + const filepath = `../ui/public/data/${lang.code.toLowerCase()}/${pathSportKey.toLowerCase()}/${pathNocKey.toLowerCase()}.ics`; + mkdirSync(filepath.split('/').slice(0, -1).join('/'), { recursive: true }); + + const titleComponents = []; + if (nocKey) { + titleComponents.push(`${calendar.nocs.find(n => n.key === nocKey)!.name[lang.code]}`); + } + if (sportKey) { + titleComponents.push(calendar.sports.find(s => s.key === sportKey)!.name[lang.code]); + } + titleComponents.push("Milano Cortina 2026"); + + const title = titleComponents.join(' - '); + + const lines = []; + + lines.push("BEGIN:VCALENDAR"); + lines.push("VERSION:2.0"); + lines.push(`PRODID:-//fabrice404//olympics-calendar//${lang.code}/${pathSportKey}/${pathNocKey}`); + lines.push(`X-WR-CALNAME:${title}`); + lines.push(`NAME:${title}`); + + calendar.events + .filter((event) => { + if (sportKey && event.sport !== sportKey) return false; + if (nocKey) { + if (event.match) { + const team1Key = event.match.team1.key; + const team2Key = event.match.team2.key; + if (team1Key !== nocKey && team2Key !== nocKey) { + return false; + } + } else { + return false; + } + } + return true; + }) + .forEach((event) => { + lines.push("BEGIN:VEVENT"); + lines.push(`UID:${event.key.replace(/--/g, '-')}`); + lines.push(`DTSTAMP:${event.start.replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')}`); + lines.push(`DTSTART:${event.start.replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')}`); + lines.push(`DTEND:${event.end.replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')}`); + lines.push(`LOCATION:${event.location[lang.code] || ''}`); + + const sport = calendar.sports.find(s => s.key === event.sport)!; + lines.push(`DESCRIPTION:${sport.name[lang.code]} - ${event.name[lang.code] || ''}`); + let summary = `SUMMARY:${event.name[lang.code] || ''}` + + if (event.match) { + const team1Name = event.match.team1.name[lang.code] || event.match.team1.key; + const team1Flag = getFlag(event.match.team1.key); + const team2Name = event.match.team2.name[lang.code] || event.match.team2.key; + const team2Flag = getFlag(event.match.team2.key); + if (team1Name && team2Name) { + lines.push(`SUMMARY:${team1Flag} ${team1Name} - ${team2Name} ${team2Flag}`); + } + } + + lines.push(summary); + lines.push(`END:VEVENT`); + + }) + + lines.push("END:VCALENDAR"); + + writeFileSync(filepath, lines.join('\n')); + }); +}; diff --git a/scraper/index.ts b/scraper/index.ts index 7bfe11afd..a2b55044e 100644 --- a/scraper/index.ts +++ b/scraper/index.ts @@ -1,12 +1,16 @@ import Debug from "debug"; import * as cache from "./cache"; +import { mkdirSync, writeFileSync } from "fs"; +import { Calendar, Event, Sport, Team } from "./types"; +import { generateICSFiles } from "./ics"; const baseUrl = "https://www.olympics.com"; const basePath = "/milano-cortina-2026/schedule/overview"; const debug = Debug(`olympics-calendar:index`); + const getScheduleOverview = async (language: string) => { debug(`getScheduleOverview: language=${language}`); @@ -48,7 +52,7 @@ const getScheduleSport = async (language: string, sportCode: string) => { const scheduleSport = JSON.parse(cache.get(scheduleSportKey)!); return scheduleSport; -} +}; const main = async () => { const overview = await getScheduleOverview("en"); @@ -59,9 +63,9 @@ const main = async () => { name: lang.label, })) - const sports: any = []; - const events: any[] = []; - let nocs: any[] = []; + const sports: Sport[] = []; + const events: Event[] = []; + let nocs: Team[] = []; for (const lang of languages) { const scheduleOverview = await getScheduleOverview(lang.code); @@ -76,7 +80,7 @@ const main = async () => { if (sports.find((s: any) => s.key === key) == null) { sports.push({ key, name: {}, order: -1 }) } - const sport = sports.find((s: any) => s.key === key) + const sport = sports.find((s: any) => s.key === key)!; sport.name[lang.code] = discipline.description; sport.order = discipline.order; @@ -93,10 +97,12 @@ const main = async () => { isTraining: scheduleListElement.isTraining, medal: scheduleListElement.medal, name: {}, + location: {}, }) } - const event = events.find(e => e.key === scheduleListElement.unitCode); + const event = events.find(e => e.key === scheduleListElement.unitCode)!; event.name[lang.code] = scheduleListElement.description; + event.location[lang.code] = scheduleListElement.venue?.description || '' if (scheduleListElement.match) { if (event.match == null) { @@ -114,7 +120,7 @@ const main = async () => { if (nocs.find(n => n.key === nocKey) == null) { nocs.push({ key: nocKey, name: {} }); } - const noc = nocs.find(n => n.key === nocKey); + const noc = nocs.find(n => n.key === nocKey)!; noc.name[lang.code] = (team.description || '').replace(/\,/gi, ''); } } @@ -125,7 +131,11 @@ const main = async () => { nocs = nocs.filter((noc) => noc.key !== noc.name.en); - cache.set('calendar.json', JSON.stringify({ languages, sports, nocs, events })); + const dataFolder = "../ui/public/data"; + mkdirSync(dataFolder, { recursive: true }); + const calendar: Calendar = { languages, sports, nocs, events }; + writeFileSync(`${dataFolder}/calendar.json`, JSON.stringify(calendar)); + generateICSFiles(calendar); }; main(); diff --git a/scraper/nocs.ts b/scraper/nocs.ts new file mode 100644 index 000000000..36fbddab8 --- /dev/null +++ b/scraper/nocs.ts @@ -0,0 +1,212 @@ +export const flags: { [key: string]: string } = { + AFG: "๐ฆ๐ซ", + ALB: "๐ฆ๐ฑ", + ALG: "๐ฉ๐ฟ", + AIN: "๐ฆ๐ธ", + AND: "๐ฆ๐ฉ", + ANG: "๐ฆ๐ด", + ANT: "๐ฆ๐ฌ", + ARG: "๐ฆ๐ท", + ARM: "๐ฆ๐ฒ", + ARU: "๐ฆ๐ผ", + ASA: "๐ฆ๐ธ", + AUS: "๐ฆ๐บ", + AUT: "๐ฆ๐น", + AZE: "๐ฆ๐ฟ", + BAH: "๐ง๐ธ", + BAN: "๐ง๐ฉ", + BAR: "๐ง๐ง", + BDI: "๐ง๐ฎ", + BEL: "๐ง๐ช", + BEN: "๐ง๐ฏ", + BER: "๐ง๐ฒ", + BHU: "๐ง๐น", + BIH: "๐ง๐ฆ", + BIZ: "๐ง๐ฟ", + BOL: "๐ง๐ด", + BOT: "๐ง๐ผ", + BRA: "๐ง๐ท", + BRN: "๐ง๐ญ", + BRU: "๐ง๐ณ", + BUL: "๐ง๐ฌ", + BUR: "๐ง๐ซ", + CAF: "๐จ๐ซ", + CAM: "๐ฐ๐ญ", + CAN: "๐จ๐ฆ", + CAY: "๐ฐ๐พ", + CGO: "๐จ๐ฌ", + CHA: "๐น๐ฉ", + CHI: "๐จ๐ฑ", + CHN: "๐จ๐ณ", + CIV: "๐จ๐ฎ", + CMR: "๐จ๐ฒ", + COD: "๐จ๐ฉ", + COK: "๐จ๐ฐ", + COL: "๐จ๐ด", + COM: "๐ฐ๐ฒ", + CPV: "๐จ๐ป", + CRC: "๐จ๐ท", + CRO: "๐ญ๐ท", + CUB: "๐จ๐บ", + CYP: "๐จ๐พ", + CZE: "๐จ๐ฟ", + DEN: "๐ฉ๐ฐ", + DJI: "๐ฉ๐ฏ", + DMA: "๐ฉ๐ฒ", + DOM: "๐ฉ๐ด", + ECU: "๐ช๐จ", + EGY: "๐ช๐ฌ", + EOR: "๐ณ๏ธ", + ERI: "๐ช๐ท", + ESA: "๐ธ๐ป", + ESP: "๐ช๐ธ", + EST: "๐ช๐ช", + ETH: "๐ช๐น", + FIJ: "๐ซ๐ฏ", + FIN: "๐ซ๐ฎ", + FRA: "๐ซ๐ท", + FSM: "๐ซ๐ฒ", + GAB: "๐ฌ๐ฆ", + GAM: "๐ฌ๐ฒ", + GBR: "๐ฌ๐ง", + GBS: "๐ฌ๐ผ", + GEO: "๐ฌ๐ช", + GEQ: "๐ฌ๐ถ", + GER: "๐ฉ๐ช", + GHA: "๐ฌ๐ญ", + GRE: "๐ฌ๐ท", + GRN: "๐ฌ๐ฉ", + GUA: "๐ฌ๐น", + GUI: "๐ฌ๐ณ", + GUM: "๐ฌ๐บ", + GUY: "๐ฌ๐พ", + HAI: "๐ญ๐น", + HKG: "๐ญ๐ฐ", + HON: "๐ญ๐ณ", + HUN: "๐ญ๐บ", + INA: "๐ฎ๐ฉ", + IND: "๐ฎ๐ณ", + IRI: "๐ฎ๐ท", + IRL: "๐ฎ๐ช", + IRQ: "๐ฎ๐ถ", + ISL: "๐ฎ๐ธ", + ISR: "๐ฎ๐ฑ", + ISV: "๐ป๐ฎ", + ITA: "๐ฎ๐น", + IVB: "๐ป๐ฌ", + JAM: "๐ฏ๐ฒ", + JOR: "๐ฏ๐ด", + JPN: "๐ฏ๐ต", + KAZ: "๐ฐ๐ฟ", + KEN: "๐ฐ๐ช", + KGZ: "๐ฐ๐ฌ", + KIR: "๐ฐ๐ฎ", + KOR: "๐ฐ๐ท", + KOS: "๐ฝ๐ฐ", + KSA: "๐ธ๐ฆ", + KUW: "๐ฐ๐ผ", + LAO: "๐ฑ๐ฆ", + LAT: "๐ฑ๐ป", + LBA: "๐ฑ๐พ", + LBN: "๐ฑ๐ง", + LBR: "๐ฑ๐ท", + LCA: "๐ฑ๐จ", + LES: "๐ฑ๐ธ", + LIE: "๐ฑ๐ฎ", + LTU: "๐ฑ๐น", + LUX: "๐ฑ๐บ", + MAD: "๐ฒ๐ฌ", + MAR: "๐ฒ๐ฆ", + MAS: "๐ฒ๐พ", + MAW: "๐ฒ๐ผ", + MDA: "๐ฒ๐ฉ", + MDV: "๐ฒ๐ป", + MEX: "๐ฒ๐ฝ", + MGL: "๐ฒ๐ณ", + MHL: "๐ฒ๐ญ", + MKD: "๐ฒ๐ฐ", + MLI: "๐ฒ๐ฑ", + MLT: "๐ฒ๐น", + MNE: "๐ฒ๐ช", + MON: "๐ฒ๐จ", + MOZ: "๐ฒ๐ฟ", + MRI: "๐ฒ๐บ", + MTN: "๐ฒ๐ท", + MYA: "๐ฒ๐ฒ", + NAM: "๐ณ๐ฆ", + NCA: "๐ณ๐ฎ", + NED: "๐ณ๐ฑ", + NEP: "๐ณ๐ต", + NGR: "๐ณ๐ฌ", + NIG: "๐ณ๐ช", + NOR: "๐ณ๐ด", + NRU: "๐ณ๐ท", + NZL: "๐ณ๐ฟ", + OMA: "๐ด๐ฒ", + PAK: "๐ต๐ฐ", + PAN: "๐ต๐ฆ", + PAR: "๐ต๐พ", + PER: "๐ต๐ช", + PHI: "๐ต๐ญ", + PLE: "๐ต๐ธ", + PLW: "๐ต๐ผ", + PNG: "๐ต๐ฌ", + POL: "๐ต๐ฑ", + POR: "๐ต๐น", + PRK: "๐ฐ๐ต", + PUR: "๐ต๐ท", + QAT: "๐ถ๐ฆ", + ROU: "๐ท๐ด", + RSA: "๐ฟ๐ฆ", + RWA: "๐ท๐ผ", + SAM: "๐ผ๐ธ", + SEN: "๐ธ๐ณ", + SEY: "๐ธ๐จ", + SGP: "๐ธ๐ฌ", + SKN: "๐ฐ๐ณ", + SLE: "๐ธ๐ฑ", + SLO: "๐ธ๐ฎ", + SMR: "๐ธ๐ฒ", + SOL: "๐ธ๐ง", + SOM: "๐ธ๐ด", + SRB: "๐ท๐ธ", + SRI: "๐ฑ๐ฐ", + SSD: "๐ธ๐ธ", + STP: "๐ธ๐น", + SUD: "๐ธ๐ฉ", + SUI: "๐จ๐ญ", + SUR: "๐ธ๐ท", + SVK: "๐ธ๐ฐ", + SWE: "๐ธ๐ช", + SWZ: "๐ธ๐ฟ", + SYR: "๐ธ๐พ", + TAN: "๐น๐ฟ", + TGA: "๐น๐ด", + THA: "๐น๐ญ", + TJK: "๐น๐ฏ", + TKM: "๐น๐ฒ", + TLS: "๐น๐ฑ", + TOG: "๐น๐ฌ", + TPE: "๐น๐ผ", + TTO: "๐น๐น", + TUN: "๐น๐ณ", + TUR: "๐น๐ท", + TUV: "๐น๐ป", + UAE: "๐ฆ๐ช", + UGA: "๐บ๐ฌ", + UKR: "๐บ๐ฆ", + URU: "๐บ๐พ", + USA: "๐บ๐ธ", + UZB: "๐บ๐ฟ", + VAN: "๐ป๐บ", + VEN: "๐ป๐ช", + VIE: "๐ป๐ณ", + VIN: "๐ป๐จ", + YEM: "๐พ๐ช", + ZAM: "๐ฟ๐ฒ", + ZIM: "๐ฟ๐ผ", +}; + +export const getFlag = (nocKey: string): string => { + return flags[nocKey.toUpperCase()] || "๐ณ๏ธ"; +} diff --git a/scraper/package.json b/scraper/package.json index 1b714d65d..2835cfb33 100644 --- a/scraper/package.json +++ b/scraper/package.json @@ -7,7 +7,9 @@ "type": "commonjs", "main": "index.js", "scripts": { - "dev": "DEBUG=olympics-calendar* nodemon index.ts" + "start": "find ./cache/**/*.cached -mmin +10 -exec rm -f {} \\; | DEBUG=olympics-calendar* ts-node index.ts", + "dev": "DEBUG=olympics-calendar* nodemon index.ts", + "lint": "eslint . --ext .ts" }, "dependencies": { "debug": "^4.4.3", diff --git a/scraper/types.d.ts b/scraper/types.d.ts new file mode 100644 index 000000000..c432b4e70 --- /dev/null +++ b/scraper/types.d.ts @@ -0,0 +1,44 @@ + +export interface MultilingualString { + [key: string]: string; +} + +export interface Language { + code: string; + name: string; +} + +export interface Sport { + key: string; + name: MultilingualString; + order: number; +} + +export interface Team { + key: string; + name: MultilingualString; +} + +export interface Match { + team1: Team; + team2: Team; +} + +export interface Event { + key: string; + start: string; + end: string; + sport: string; + isTraining: boolean; + medal: '0' | '1' | '3'; + name: MultilingualString; + location: MultilingualString; + match?: Match; +} + +export interface Calendar { + languages: Language[]; + sports: Sport[]; + events: Event[]; + nocs: Team[]; +} diff --git a/test.html b/test.html new file mode 100644 index 000000000..df5e1f966 --- /dev/null +++ b/test.html @@ -0,0 +1,215 @@ + + +
+ +