Compare commits

..

22 Commits

Author SHA1 Message Date
7ab818dd48 Api/Scrape: Implement serverside api route to fetch official f1.com data
All checks were successful
Build Formula11 Docker Image / pocketbase-docker (push) Successful in 37s
2025-03-25 20:09:54 +01:00
1d7dff1b53 Seasonpicks: Update depended data 2025-03-25 19:51:37 +01:00
18efab45d3 Racepicks: Update depended data 2025-03-25 19:51:31 +01:00
f75c880c25 Data/Raceresults: Update depended data 2025-03-25 19:51:23 +01:00
20dfc45f89 Data/Official: Implement driverstandings page 2025-03-25 19:45:37 +01:00
3b710bd846 Data/Official: Implement raceresults page 2025-03-25 19:45:30 +01:00
5485425213 Data/Official: Implement teamstandings page 2025-03-25 19:45:23 +01:00
a9252e92f7 Skeleton: Subscribe to official/scraped pocketbase tables 2025-03-25 19:45:15 +01:00
baaa5f1c61 Data/Official: Add official data page skeleton 2025-03-25 19:32:00 +01:00
6239cdef5d Skeleton: Add data drawer button for official data 2025-03-25 19:31:39 +01:00
a05c0e6882 Data/Users: Update table height 2025-03-25 19:31:25 +01:00
756004476d Season/Teams: Update table height 2025-03-25 19:31:20 +01:00
0e0203c4f7 Season/Substitutions: Update table height 2025-03-25 19:31:13 +01:00
7e24a43312 Season/Races: Update table height 2025-03-25 19:31:06 +01:00
4bfa15f4aa Season/Drivers: Update table height 2025-03-25 19:31:01 +01:00
f3eb710403 Data/Raceresults: Update table height 2025-03-25 19:30:43 +01:00
bc158b6060 Lib: Add scraper library functions for results/team standings/driver standings 2025-03-25 19:30:10 +01:00
2fa2d7006b Lib: Add ScrapedDriverStanding + ScrapedTeamStanding to schema 2025-03-25 19:29:48 +01:00
36b4aea1c2 Lib: Add pocketbase fetchers for scraped data 2025-03-25 19:29:32 +01:00
4a497aefb4 Lib: Add optional height classes to component + make thead sticky 2025-03-25 19:29:16 +01:00
22a6da55fa Lib: Add ScrapedRaceResult to schema 2025-03-25 18:00:58 +01:00
b0859ff147 Env: Add cheerio node dep 2025-03-25 18:00:46 +01:00
24 changed files with 865 additions and 13 deletions

294
package-lock.json generated
View File

@ -21,6 +21,7 @@
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@types/node": "^22.13.10", "@types/node": "^22.13.10",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"cheerio": "^1.0.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"pocketbase": "^0.25.2", "pocketbase": "^0.25.2",
"postcss": "^8.5.3", "postcss": "^8.5.3",
@ -1839,6 +1840,13 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"dev": true,
"license": "ISC"
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
@ -1926,6 +1934,50 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/cheerio": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz",
"integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==",
"dev": true,
"license": "MIT",
"dependencies": {
"cheerio-select": "^2.1.0",
"dom-serializer": "^2.0.0",
"domhandler": "^5.0.3",
"domutils": "^3.1.0",
"encoding-sniffer": "^0.2.0",
"htmlparser2": "^9.1.0",
"parse5": "^7.1.2",
"parse5-htmlparser2-tree-adapter": "^7.0.0",
"parse5-parser-stream": "^7.1.2",
"undici": "^6.19.5",
"whatwg-mimetype": "^4.0.0"
},
"engines": {
"node": ">=18.17"
},
"funding": {
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
}
},
"node_modules/cheerio-select": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-select": "^5.1.0",
"css-what": "^6.1.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@ -2045,6 +2097,36 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/css-select": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz",
"integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-what": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
"integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/cssesc": { "node_modules/cssesc": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -2127,6 +2209,65 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/eastasianwidth": { "node_modules/eastasianwidth": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@ -2148,6 +2289,33 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/encoding-sniffer": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz",
"integrity": "sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "^0.6.3",
"whatwg-encoding": "^3.1.1"
},
"funding": {
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.1", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz",
@ -2405,6 +2573,39 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/htmlparser2": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz",
"integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==",
"dev": true,
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.1.0",
"entities": "^4.5.0"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/import-meta-resolve": { "node_modules/import-meta-resolve": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",
@ -2756,6 +2957,19 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -2783,6 +2997,46 @@
"dev": true, "dev": true,
"license": "BlueOak-1.0.0" "license": "BlueOak-1.0.0"
}, },
"node_modules/parse5": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz",
"integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^4.5.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-htmlparser2-tree-adapter": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"domhandler": "^5.0.3",
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-parser-stream": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
"dev": true,
"license": "MIT",
"dependencies": {
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-key": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -3280,6 +3534,13 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.1", "version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
@ -3834,6 +4095,16 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/undici": {
"version": "6.21.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.2.tgz",
"integrity": "sha512-uROZWze0R0itiAKVPsYhFov9LxrPMHLMEQFszeI2gCN6bnIIZ8twzBCJcN2LJrBBLfrP0t1FW0g+JmKVl8Vk1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.20.0", "version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
@ -3984,6 +4255,29 @@
} }
} }
}, },
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -20,6 +20,7 @@
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@types/node": "^22.13.10", "@types/node": "^22.13.10",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"cheerio": "^1.0.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"pocketbase": "^0.25.2", "pocketbase": "^0.25.2",
"postcss": "^8.5.3", "postcss": "^8.5.3",

View File

@ -8,17 +8,20 @@
/** The columns the table should have. */ /** The columns the table should have. */
columns: TableColumn[]; columns: TableColumn[];
/** Optional height classes */
height?: string;
/** An optional function handling clicking on a table row */ /** An optional function handling clicking on a table row */
handler?: (event: Event, id: string) => Promise<void>; handler?: (event: Event, id: string) => Promise<void>;
} }
let { data, columns, handler = undefined }: TableProps = $props(); let { data, columns, height = "", handler = undefined }: TableProps = $props();
</script> </script>
{#if data.length > 0} {#if data.length > 0}
<div class="table-container bg-white shadow"> <div class="table-container bg-white shadow {height}">
<table class="table table-compact bg-white"> <table class="table table-compact !overflow-scroll bg-white">
<thead> <thead class="sticky top-0">
<tr class="bg-surface-500"> <tr class="bg-surface-500">
{#each columns as col} {#each columns as col}
<th class="!px-3">{col.label}</th> <th class="!px-3">{col.label}</th>

View File

@ -10,6 +10,9 @@ import type {
RacePickPoints, RacePickPoints,
RacePickPointsAcc, RacePickPointsAcc,
RaceResult, RaceResult,
ScrapedDriverStanding,
ScrapedRaceResult,
ScrapedTeamStanding,
SeasonPick, SeasonPick,
SeasonPickedUser, SeasonPickedUser,
Substitution, Substitution,
@ -289,3 +292,42 @@ export const fetch_racepickpointsacc = async (
return racepickpointsacc; return racepickpointsacc;
}; };
/**
* Fetch all [ScrapedDriverStandings] from the database, ordered ascendingly by position.
*/
export const fetch_scraped_driverstandings = async (
fetch: (_: any) => Promise<Response>,
): Promise<ScrapedDriverStanding[]> => {
const scraped_driverstandings: ScrapedDriverStanding[] = await pb
.collection("scraped_driverstandings")
.getFullList({ fetch: fetch, sort: "+position" });
return scraped_driverstandings;
};
/**
* Fetch all [ScrapedTeamStandings] from the database, ordered ascendingly by position.
*/
export const fetch_scraped_teamstandings = async (
fetch: (_: any) => Promise<Response>,
): Promise<ScrapedTeamStanding[]> => {
const scraped_teamstandings: ScrapedTeamStanding[] = await pb
.collection("scraped_teamstandings")
.getFullList({ fetch: fetch, sort: "+position" });
return scraped_teamstandings;
};
/**
* Fetch all [ScrapedRaceResults] from the database, ordered descendingly by race step.
*/
export const fetch_scraped_raceresults = async (
fetch: (_: any) => Promise<Response>,
): Promise<ScrapedRaceResult[]> => {
const scraped_raceresults: ScrapedRaceResult[] = await pb
.collection("scraped_raceresults")
.getFullList({ fetch: fetch, sort: "-race_step,+position" });
return scraped_raceresults;
};

View File

@ -145,3 +145,28 @@ export interface RacePickPointsAcc {
total_dnf_points: number; total_dnf_points: number;
total_points: number; total_points: number;
} }
// Scraped Data
export interface ScrapedRaceResult {
id: string;
race_step: number; // This maps to races
driver_code: string; // This maps to drivers
position: number;
status: string; // Either contains time to leader or DNF/DSQ...
points: number;
}
export interface ScrapedDriverStanding {
id: string;
driver_code: string; // This maps to drivers
position: number;
points: number;
}
export interface ScrapedTeamStanding {
id: string;
team_fullname: string; // TODO: This does NOT map to teams! Add fullname to team data!
position: number;
points: number;
}

122
src/lib/server/scrape.ts Normal file
View File

@ -0,0 +1,122 @@
import type { ScrapedDriverStanding, ScrapedRaceResult, ScrapedTeamStanding } from "$lib/schema";
import * as cheerio from "cheerio";
// TODO: Validate the generated stuff
export const base_url: string = "https://www.formula1.com/en/results/2025";
/**
* Returns a list of links to all past races of the season,
* based on official f1.com data.
*/
export const scrape_race_links = async (): Promise<string[]> => {
const races_response = await fetch(`${base_url}/races`);
const races_text = await races_response.text();
const $ = cheerio.load(races_text);
const race_links: string[] = [];
$("tbody > tr > td:first-child > p > a[href]", "div.f1-inner-wrapper table.f1-table").each(
(_, element) => {
race_links.push(element.attribs["href"]);
},
);
console.log(`Found ${race_links.length} races...`);
return race_links;
};
/**
* Returns a list of [ScrapedRaceResults] for all races contained in [race_links],
* based on official f1.com data.
*/
export const scrape_race_results = async (race_links: string[]): Promise<ScrapedRaceResult[]> => {
const race_results: ScrapedRaceResult[] = [];
await Promise.all(
race_links.map(async (link: string, index: number) => {
console.log(`Fetching race results from ${base_url}/${link}...`);
const race_response = await fetch(`${base_url}/${link}`);
const race_text = await race_response.text();
const $ = cheerio.load(race_text);
// Obtain the results for this race for each driver
$("tbody > tr", "div.f1-inner-wrapper table.f1-table").each((driver_index, element) => {
const $$ = cheerio.load(element);
let result: ScrapedRaceResult = {
id: "",
race_step: index + 1,
driver_code: $$("td:nth-child(3) > p > span:last-child").text(),
position: driver_index + 1, // parseInt($$("td:nth-child(1) > p").text()),
status: $$("td:nth-child(6) > p").text(),
points: parseInt($$("td:nth-child(7) > p").text()),
};
// DSQ'd/DNF'd drivers have NaN positions
// if (Number.isNaN(result.position)) {
// result.position = driver_index;
// }
race_results.push(result);
});
}),
);
console.log(`Scraped ${race_results.length} race results...`);
return race_results;
};
/**
* Returns a list of [ScrapedDriverStandings], based on official f1.com data.
*/
export const scrape_driver_standings = async (): Promise<ScrapedDriverStanding[]> => {
const standings_response = await fetch(`${base_url}/drivers`);
const standings_text = await standings_response.text();
const $ = cheerio.load(standings_text);
const driver_standings: ScrapedDriverStanding[] = [];
$("tbody > tr", "div.f1-inner-wrapper table.f1-table").each((driver_index, element) => {
const $$ = cheerio.load(element);
let standing: ScrapedDriverStanding = {
id: "",
driver_code: $$("td:nth-child(2) > p > a > span:last-child").text(),
position: driver_index + 1,
points: parseInt($$("td:nth-child(5) > p").text()),
};
driver_standings.push(standing);
});
console.log(`Scraped ${driver_standings.length} driver standings...`);
return driver_standings;
};
/**
* Returns a list of [ScrapedTeamStandings], based on official f1.com data.
*/
export const scrape_team_standings = async (): Promise<ScrapedTeamStanding[]> => {
const standings_response = await fetch(`${base_url}/team`);
const standings_text = await standings_response.text();
const $ = cheerio.load(standings_text);
const team_standings: ScrapedTeamStanding[] = [];
$("tbody > tr", "div.f1-inner-wrapper table.f1-table").each((team_index, element) => {
const $$ = cheerio.load(element);
let standing: ScrapedTeamStanding = {
id: "",
team_fullname: $$("td:nth-child(2) > p > a").text(),
position: team_index + 1,
points: parseInt($$("td:nth-child(3) > p").text()),
};
team_standings.push(standing);
});
console.log(`Scraped ${team_standings.length} team standings...`);
return team_standings;
};

View File

@ -327,6 +327,9 @@
"seasonpicks", "seasonpicks",
"substitutions", "substitutions",
"teams", "teams",
"scraped_raceresults",
"scraped_driverstandings",
"scraped_teamstandings",
// The view collections do not receive realtime events // The view collections do not receive realtime events
]), ]),
@ -342,6 +345,9 @@
"seasonpicks", "seasonpicks",
"substitutions", "substitutions",
"teams", "teams",
"scraped_raceresults",
"scraped_driverstandings",
"scraped_teamstandings",
]), ]),
); );
</script> </script>
@ -395,6 +401,15 @@
<Button href="/data/users" onclick={close_drawer} color="surface" width="w-full" shadow> <Button href="/data/users" onclick={close_drawer} color="surface" width="w-full" shadow>
Users Users
</Button> </Button>
<Button
href="/data/official/driverstandings"
onclick={close_drawer}
color="surface"
width="w-full"
shadow
>
Official
</Button>
</div> </div>
{:else if $drawerStore.id === "login_drawer"} {:else if $drawerStore.id === "login_drawer"}
<!-- Login Drawer --> <!-- Login Drawer -->

View File

@ -0,0 +1,125 @@
import {
fetch_scraped_driverstandings,
fetch_scraped_raceresults,
fetch_scraped_teamstandings,
} from "$lib/fetch";
import { pb } from "$lib/pocketbase";
import type { ScrapedDriverStanding, ScrapedRaceResult, ScrapedTeamStanding } from "$lib/schema";
import {
scrape_driver_standings,
scrape_race_links,
scrape_race_results,
scrape_team_standings,
} from "$lib/server/scrape";
import type { RequestHandler } from "./$types";
/**
* This route is available at /api/scrape.
* It will fetch current statistics from f1.com and insert them into the database.
*/
// TODO: If this function aborts, it will leave the official data in an inconsistent state...
// Would be nice to use transactions for this, do I need to implement this as PB extension?
export const POST: RequestHandler = async ({ request }) => {
console.log("Fetching race results from f1.com...");
// Obtain the results for each race
const racelinks: string[] = await scrape_race_links();
const raceresults: ScrapedRaceResult[] = await scrape_race_results(racelinks);
const driverstandings: ScrapedDriverStanding[] = await scrape_driver_standings();
const teamstandings: ScrapedTeamStanding[] = await scrape_team_standings();
// Clear existing PocketBase data
let deleted: number = 0;
const scraped_raceresults: ScrapedRaceResult[] = await fetch_scraped_raceresults(fetch);
for (const result of scraped_raceresults) {
try {
await pb.collection("scraped_raceresults").delete(result.id);
} catch (e) {
console.log(e);
return new Response(); // TODO: Return error
}
deleted++;
}
console.log(`Deleted ${deleted}/${scraped_raceresults.length} race results.`);
deleted = 0;
const scraped_driverstandings: ScrapedDriverStanding[] =
await fetch_scraped_driverstandings(fetch);
for (const standing of scraped_driverstandings) {
try {
await pb.collection("scraped_driverstandings").delete(standing.id);
} catch (e) {
console.log(e);
return new Response(); // TODO: Return error
}
deleted++;
}
console.log(`Deleted ${deleted}/${scraped_driverstandings.length} driver standings.`);
deleted = 0;
const scraped_teamstandings: ScrapedTeamStanding[] = await fetch_scraped_teamstandings(fetch);
for (const standing of scraped_teamstandings) {
try {
await pb.collection("scraped_teamstandings").delete(standing.id);
} catch (e) {
console.log(e);
return new Response(); // TODO: Return error
}
deleted++;
}
console.log(`Deleted ${deleted}/${scraped_teamstandings.length} team standings.`);
// Submit new data to PocketBase
let submissions: number = 0;
for (const result of raceresults) {
try {
// TODO: Authenticate this
await pb.collection("scraped_raceresults").create(result);
} catch (e) {
console.log("Error occured while submitting scraped data to PocketBase:");
console.log(e);
console.log("Error occured for this race result:");
console.log(result);
console.log("Aborting submissions...");
return new Response(); // TODO: Return error
}
submissions++;
}
console.log(`Submitted ${submissions}/${raceresults.length} race results.`);
submissions = 0;
for (const standing of driverstandings) {
try {
// TODO: Authenticate this
await pb.collection("scraped_driverstandings").create(standing);
} catch (e) {
console.log("Error occured while submitting scraped data to PocketBase:");
console.log(e);
console.log("Error occured for this driver standing:");
console.log(standing);
console.log("Aborting submissions...");
return new Response(); // TODO: Return error
}
submissions++;
}
console.log(`Submitted ${submissions}/${driverstandings.length} driver standings.`);
submissions = 0;
for (const standing of teamstandings) {
try {
// TODO: Authenticate this
await pb.collection("scraped_teamstandings").create(standing);
} catch (e) {
console.log("Error occured while submitting scraped data to PocketBase:");
console.log(e);
console.log("Error occured for this team standing:");
console.log(standing);
console.log("Aborting submissions...");
return new Response(); // TODO: Return error
}
submissions++;
}
console.log(`Submitted ${submissions}/${teamstandings.length} team standings.`);
return new Response(); // TODO: Return success
};

View File

@ -0,0 +1,31 @@
<script lang="ts">
import { Button } from "$lib/components";
import type { Snippet } from "svelte";
let { children }: { children: Snippet } = $props();
const scrape_official_data = async () => {
// TODO: Success/error toast
const response: Response = await fetch("/api/scrape", { method: "POST" });
};
</script>
<div class="fixed left-0 right-0 top-14 z-10 flex justify-center">
<div
class="mx-2 flex w-full justify-center gap-2 bg-primary-500 pb-2 pt-3 shadow rounded-bl-container-token rounded-br-container-token"
>
<Button href="driverstandings" color="primary" activate_href>Drivers</Button>
<Button href="teamstandings" color="primary" activate_href>Teams</Button>
<Button href="raceresults" color="primary" activate_href>Race Results</Button>
</div>
</div>
<!-- Each child's contents will be inserted here -->
<div style="margin-top: 56px;">
<div class="pb-2">
<Button width="w-full" color="tertiary" onclick={scrape_official_data} shadow>
<span class="font-bold">Refresh All Data</span>
</Button>
</div>
{@render children()}
</div>

View File

@ -0,0 +1,37 @@
<script lang="ts">
import { Table, type TableColumn } from "$lib/components";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
const standings_columns: TableColumn[] = $derived([
{
data_value_name: "driver_code",
label: "Driver",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{
data_value_name: "position",
label: "Position",
},
{
data_value_name: "points",
label: "Points",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
]);
</script>
<svelte:head>
<title>Formula 11 - Official Driver Standings</title>
</svelte:head>
{#await data.scraped_driverstandings then standings}
<Table
data={standings}
columns={standings_columns}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await}

View File

@ -0,0 +1,11 @@
import { fetch_drivers, fetch_scraped_driverstandings } from "$lib/fetch";
import type { PageLoad } from "../../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:scraped_driverstandings", "data:drivers");
return {
scraped_driverstandings: fetch_scraped_driverstandings(fetch),
drivers: fetch_drivers(fetch),
};
};

View File

@ -0,0 +1,55 @@
<script lang="ts">
import { Table, type TableColumn } from "$lib/components";
import type { PageData } from "./$types";
import { get_by_value } from "$lib/database";
import type { Race } from "$lib/schema";
let { data }: { data: PageData } = $props();
const results_columns: TableColumn[] = $derived([
{
data_value_name: "race_step",
label: "Race",
valuefun: async (value: string): Promise<string> => {
const racename: string = get_by_value(await data.races, "step", value)?.name ?? "Invalid";
return `<span class='badge variant-filled-surface'>${racename}</span>`;
},
},
{
data_value_name: "race_step",
label: "Step",
},
{
data_value_name: "driver_code",
label: "Driver",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{
data_value_name: "position",
label: "Position",
},
{
data_value_name: "status",
label: "Status",
},
{
data_value_name: "points",
label: "Points",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
]);
</script>
<svelte:head>
<title>Formula 11 - Official Race Results</title>
</svelte:head>
{#await data.scraped_raceresults then results}
<Table
data={results}
columns={results_columns}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await}

View File

@ -0,0 +1,12 @@
import { fetch_drivers, fetch_races, fetch_scraped_raceresults } from "$lib/fetch";
import type { PageLoad } from "../../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:scraped_raceresults", "data:races", "data:drivers");
return {
scraped_raceresults: fetch_scraped_raceresults(fetch),
races: fetch_races(fetch),
drivers: fetch_drivers(fetch),
};
};

View File

@ -0,0 +1,37 @@
<script lang="ts">
import { Table, type TableColumn } from "$lib/components";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
const standings_columns: TableColumn[] = $derived([
{
data_value_name: "team_fullname",
label: "Team",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
{
data_value_name: "position",
label: "Position",
},
{
data_value_name: "points",
label: "Points",
valuefun: async (value: string): Promise<string> =>
`<span class='badge variant-filled-surface'>${value}</span>`,
},
]);
</script>
<svelte:head>
<title>Formula 11 - Official Team Standings</title>
</svelte:head>
{#await data.scraped_teamstandings then standings}
<Table
data={standings}
columns={standings_columns}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await}

View File

@ -0,0 +1,11 @@
import { fetch_scraped_teamstandings, fetch_teams } from "$lib/fetch";
import type { PageLoad } from "../../../$types";
export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:scraped_teamstandings", "data:teams");
return {
scraped_teamstandings: fetch_scraped_teamstandings(fetch),
teams: fetch_teams(fetch),
};
};

View File

@ -97,5 +97,10 @@
</Button> </Button>
</div> </div>
{#await data.raceresults then results} {#await data.raceresults then results}
<Table data={results} columns={results_columns} handler={result_handler} /> <Table
data={results}
columns={results_columns}
handler={result_handler}
height="h-[calc(100vh-210px)] lg:h-[calc(100vh-126px)]"
/>
{/await} {/await}

View File

@ -2,7 +2,7 @@ import { fetch_drivers, fetch_raceresults, fetch_races, fetch_substitutions } fr
import type { PageLoad } from "../../$types"; import type { PageLoad } from "../../$types";
export const load: PageLoad = async ({ fetch, depends }) => { export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:drivers", "data:races", "data:raceresults"); depends("data:drivers", "data:races", "data:raceresults", "data:substitutions");
return { return {
drivers: fetch_drivers(fetch), drivers: fetch_drivers(fetch),

View File

@ -61,5 +61,10 @@
</Button> </Button>
</div> </div>
{#await data.drivers then drivers} {#await data.drivers then drivers}
<Table data={drivers} columns={drivers_columns} handler={driver_handler} /> <Table
data={drivers}
columns={drivers_columns}
handler={driver_handler}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await} {/await}

View File

@ -69,5 +69,10 @@
</Button> </Button>
</div> </div>
{#await data.races then races} {#await data.races then races}
<Table data={races} columns={races_columns} handler={race_handler} /> <Table
data={races}
columns={races_columns}
handler={race_handler}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await} {/await}

View File

@ -69,5 +69,10 @@
</Button> </Button>
</div> </div>
{#await data.substitutions then substitutions} {#await data.substitutions then substitutions}
<Table data={substitutions} columns={substitutions_columns} handler={substitution_handler} /> <Table
data={substitutions}
columns={substitutions_columns}
handler={substitution_handler}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await} {/await}

View File

@ -52,5 +52,10 @@
</Button> </Button>
</div> </div>
{#await data.teams then teams} {#await data.teams then teams}
<Table data={teams} columns={teams_columns} handler={team_handler} /> <Table
data={teams}
columns={teams_columns}
handler={team_handler}
height="h-[calc(100vh-260px)] lg:h-[calc(100vh-180px)]"
/>
{/await} {/await}

View File

@ -43,4 +43,9 @@
<title>Formula 11 - Users</title> <title>Formula 11 - Users</title>
</svelte:head> </svelte:head>
<Table data={data.users} columns={users_columns} handler={users_handler} /> <Table
data={data.users}
columns={users_columns}
handler={users_handler}
height="h-[calc(100vh-160px)] lg:h-[calc(100vh-76px)]"
/>

View File

@ -12,12 +12,13 @@ import type { PageLoad } from "../$types";
export const load: PageLoad = async ({ fetch, depends }) => { export const load: PageLoad = async ({ fetch, depends }) => {
depends( depends(
"data:racepicks",
"data:user", "data:user",
"data:racepicks",
"data:users", "data:users",
"data:raceresults", "data:raceresults",
"data:drivers", "data:drivers",
"data:races", "data:races",
"data:substitutions",
); );
return { return {

View File

@ -10,7 +10,7 @@ import {
import type { PageLoad } from "../$types"; import type { PageLoad } from "../$types";
export const load: PageLoad = async ({ fetch, depends }) => { export const load: PageLoad = async ({ fetch, depends }) => {
depends("data:teams", "data:drivers", "data:seasonpicks", "data:user"); depends("data:teams", "data:drivers", "data:seasonpicks", "data:user", "data:users");
return { return {
teams: fetch_teams(fetch), teams: fetch_teams(fetch),