Spaces:
Sleeping
Sleeping
Download server/index.js from derek-thomas/dupr_explorer_may_2026: direct link, hf CLI and curl.
- Browser
- Download file 23.9 kB
-
https://huggingface.co/spaces/derek-thomas/dupr_explorer_may_2026/resolve/main/server/index.js
- Command line
-
hf download hf://spaces/derek-thomas/dupr_explorer_may_2026/server/index.js
-
curl -L -o index.js https://huggingface.co/spaces/derek-thomas/dupr_explorer_may_2026/resolve/main/server/index.js
23.9 kB
| import express from "express"; | |
| import "dotenv/config"; | |
| import fs from "node:fs/promises"; | |
| import path from "node:path"; | |
| import { fileURLToPath } from "node:url"; | |
| import { checkRepoAccess, downloadFile, uploadFile } from "@huggingface/hub"; | |
| const DUPR_SEARCH_URL = "https://api.dupr.gg/player/v1.0/search"; | |
| const DUPR_PLAYER_URL = "https://api.dupr.gg/player/v1.0"; | |
| const PORT = Number(process.env.PORT || 7860); | |
| const DEFAULT_LAT = 24.453884; | |
| const DEFAULT_LNG = 54.3773438; | |
| const EXPLORER_URL_PATTERN = | |
| process.env.DUPR_EXPLORER_URL_PATTERN || "https://duprexplorer.com/player/{numericPlayerId}"; | |
| const HF_DATASET_REPO = process.env.HF_DATASET_REPO || "derek-thomas/dupr_explorer_may_2026"; | |
| const HF_CACHE_PATH = process.env.HF_CACHE_PATH || "player-cache.json"; | |
| const HF_TOKEN = process.env.HF_TOKEN || process.env.HUGGINGFACE_HUB_TOKEN; | |
| const __filename = fileURLToPath(import.meta.url); | |
| const __dirname = path.dirname(__filename); | |
| const rootPath = path.resolve(__dirname, ".."); | |
| const distPath = path.resolve(rootPath, "dist"); | |
| const rosterPath = path.resolve(rootPath, "dupr_players.csv"); | |
| const cacheDir = path.resolve(rootPath, ".cache"); | |
| const cachePath = path.resolve(cacheDir, "player-cache.json"); | |
| let hydrateCachePromise = null; | |
| let hfDatasetAccessPromise = null; | |
| let lastCacheBackup = { | |
| status: HF_TOKEN ? "pending" : "disabled", | |
| message: HF_TOKEN ? "Waiting for cache changes." : "Set HF_TOKEN to enable Hugging Face dataset backup.", | |
| fetchedAt: null, | |
| pushedAt: null | |
| }; | |
| const app = express(); | |
| app.use(express.json({ limit: "256kb" })); | |
| function parseCsvLine(line) { | |
| const cells = []; | |
| let value = ""; | |
| let inQuotes = false; | |
| for (let index = 0; index < line.length; index += 1) { | |
| const character = line[index]; | |
| const nextCharacter = line[index + 1]; | |
| if (character === '"' && inQuotes && nextCharacter === '"') { | |
| value += '"'; | |
| index += 1; | |
| continue; | |
| } | |
| if (character === '"') { | |
| inQuotes = !inQuotes; | |
| continue; | |
| } | |
| if (character === "," && !inQuotes) { | |
| cells.push(value.trim()); | |
| value = ""; | |
| continue; | |
| } | |
| value += character; | |
| } | |
| cells.push(value.trim()); | |
| return cells; | |
| } | |
| function parseCsv(text) { | |
| const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/).filter(Boolean); | |
| const seenHeaders = {}; | |
| const headers = parseCsvLine(lines[0] || "").map((header) => { | |
| const baseHeader = header.replace(/^\uFEFF/, "").trim(); | |
| seenHeaders[baseHeader] = (seenHeaders[baseHeader] ?? 0) + 1; | |
| return seenHeaders[baseHeader] === 1 ? baseHeader : `${baseHeader}_${seenHeaders[baseHeader]}`; | |
| }); | |
| return lines.slice(1).map((line) => { | |
| const cells = parseCsvLine(line); | |
| return Object.fromEntries(headers.map((header, index) => [header, cells[index] ?? ""])); | |
| }); | |
| } | |
| function normalizeStatus(value) { | |
| return String(value || "").trim() || "Unknown"; | |
| } | |
| function isValidDuprId(value) { | |
| const normalized = String(value || "").trim(); | |
| return Boolean(normalized) && normalized.toUpperCase() !== "TBA"; | |
| } | |
| function buildExplorerUrl(numericPlayerId) { | |
| if (!looksLikeNumericPlayerId(numericPlayerId)) { | |
| return null; | |
| } | |
| return EXPLORER_URL_PATTERN.replace("{numericPlayerId}", encodeURIComponent(numericPlayerId)); | |
| } | |
| function normalizeRating(value) { | |
| if (value === null || value === undefined || value === "") { | |
| return null; | |
| } | |
| if (String(value).trim().toUpperCase() === "NR") { | |
| return null; | |
| } | |
| const numericValue = Number(value); | |
| return Number.isFinite(numericValue) ? numericValue.toFixed(3) : String(value); | |
| } | |
| function readPath(object, paths) { | |
| for (const pathParts of paths) { | |
| let value = object; | |
| for (const part of pathParts) { | |
| value = value && typeof value === "object" ? value[part] : undefined; | |
| } | |
| if (value !== undefined && value !== null && value !== "") { | |
| return value; | |
| } | |
| } | |
| return null; | |
| } | |
| function extractRatingBasics(matchedPlayer) { | |
| if (!matchedPlayer || typeof matchedPlayer !== "object") { | |
| return { | |
| singles: null, | |
| doubles: null, | |
| reliability: null, | |
| matchCount: null | |
| }; | |
| } | |
| return { | |
| singles: normalizeRating( | |
| readPath(matchedPlayer, [ | |
| ["singles"], | |
| ["singlesRating"], | |
| ["singlesDupr"], | |
| ["ratings", "singles"], | |
| ["rating", "singles"] | |
| ]) | |
| ), | |
| doubles: normalizeRating( | |
| readPath(matchedPlayer, [ | |
| ["doubles"], | |
| ["doublesRating"], | |
| ["doublesDupr"], | |
| ["ratings", "doubles"], | |
| ["rating", "doubles"] | |
| ]) | |
| ), | |
| reliability: readPath(matchedPlayer, [ | |
| ["reliability"], | |
| ["reliabilityScore"], | |
| ["confidence"], | |
| ["confidenceScore"], | |
| ["ratingReliability"] | |
| ]), | |
| matchCount: readPath(matchedPlayer, [ | |
| ["matchCount"], | |
| ["matchesCount"], | |
| ["totalMatches"], | |
| ["stats", "matchCount"], | |
| ["stats", "totalMatches"] | |
| ]) | |
| }; | |
| } | |
| function getDuprHeaders(token) { | |
| return { | |
| accept: "application/json", | |
| authorization: `Bearer ${token}`, | |
| "content-type": "application/json; charset=UTF-8", | |
| origin: "https://dashboard.dupr.com", | |
| referer: "https://dashboard.dupr.com/", | |
| "user-agent": "Mozilla/5.0" | |
| }; | |
| } | |
| async function fetchDuprJson(url, options = {}) { | |
| const token = process.env.DUPR_BEARER_TOKEN; | |
| if (!token) { | |
| throw apiError(500, "missing_token", "Missing DUPR_BEARER_TOKEN. Add it as a Hugging Face Space Secret."); | |
| } | |
| const response = await fetch(url, { | |
| ...options, | |
| headers: { | |
| ...getDuprHeaders(token), | |
| ...(options.headers || {}) | |
| }, | |
| signal: AbortSignal.timeout(20000) | |
| }); | |
| if (response.status === 401) { | |
| throw apiError(401, "invalid_token", "DUPR token appears expired or invalid. Refresh the token."); | |
| } | |
| if (response.status === 429) { | |
| throw apiError(429, "rate_limited", "DUPR rate limited the request. Try again later."); | |
| } | |
| if (!response.ok) { | |
| throw apiError(response.status, "dupr_api_error", `DUPR API returned HTTP ${response.status}.`); | |
| } | |
| try { | |
| return await response.json(); | |
| } catch { | |
| throw apiError(502, "non_json_response", "DUPR returned a response that was not JSON."); | |
| } | |
| } | |
| function summarizeMatchStats(statsData) { | |
| const overview = statsData?.result?.overview || {}; | |
| const doubles = overview.doubles || {}; | |
| const all = overview.all || {}; | |
| return { | |
| doubles: { | |
| total: Number(doubles.all ?? 0), | |
| wins: Number(doubles.wins ?? 0), | |
| losses: Number(doubles.losses ?? 0), | |
| pending: Number(doubles.pending ?? 0) | |
| }, | |
| all: { | |
| total: Number(all.all ?? 0), | |
| wins: Number(all.wins ?? 0), | |
| losses: Number(all.losses ?? 0), | |
| pending: Number(all.pending ?? 0) | |
| }, | |
| trends: statsData?.result?.trends ?? null | |
| }; | |
| } | |
| function sanitizeMatchInfo(matchInfo) { | |
| if (!matchInfo || typeof matchInfo !== "object") { | |
| return null; | |
| } | |
| return { | |
| summary: matchInfo.summary ?? null, | |
| recent: Array.isArray(matchInfo.recent) ? matchInfo.recent.slice(0, 10) : [], | |
| totalHistory: Number(matchInfo.totalHistory ?? 0), | |
| message: matchInfo.message ?? null, | |
| errorCode: matchInfo.errorCode ?? null | |
| }; | |
| } | |
| async function fetchMatchInfo(numericPlayerId) { | |
| const [statsData, historyData] = await Promise.all([ | |
| fetchDuprJson(`${DUPR_PLAYER_URL}/stats/${numericPlayerId}`), | |
| fetchDuprJson(`${DUPR_PLAYER_URL}/${numericPlayerId}/history`, { | |
| method: "POST", | |
| body: JSON.stringify({ limit: 10, offset: 0 }) | |
| }) | |
| ]); | |
| const hits = Array.isArray(historyData?.result?.hits) ? historyData.result.hits : []; | |
| return { | |
| summary: summarizeMatchStats(statsData), | |
| recent: hits.slice(0, 10), | |
| totalHistory: Number(historyData?.result?.total ?? hits.length), | |
| rawStats: statsData, | |
| rawHistory: historyData | |
| }; | |
| } | |
| function getHfRepo() { | |
| return { | |
| type: "dataset", | |
| name: HF_DATASET_REPO | |
| }; | |
| } | |
| function getHfErrorMessage(error) { | |
| if (error?.statusCode === 404) { | |
| return `Token cannot access dataset ${HF_DATASET_REPO}. Confirm the dataset repo exists, HF_DATASET_REPO matches it exactly, and HF_TOKEN has write access to that private dataset.`; | |
| } | |
| if (error?.statusCode === 401 || error?.statusCode === 403) { | |
| return `HF_TOKEN was rejected for ${HF_DATASET_REPO}. Confirm the token is valid and has write access to the dataset.`; | |
| } | |
| return "Could not access the Hugging Face dataset; local cache will still be used."; | |
| } | |
| async function ensureHfDatasetAccess() { | |
| if (!HF_TOKEN) { | |
| return; | |
| } | |
| hfDatasetAccessPromise ??= checkRepoAccess({ | |
| repo: getHfRepo(), | |
| accessToken: HF_TOKEN | |
| }); | |
| await hfDatasetAccessPromise; | |
| } | |
| function updateCacheBackupStatus(status, message, extra = {}) { | |
| lastCacheBackup = { | |
| ...lastCacheBackup, | |
| status, | |
| message, | |
| ...extra | |
| }; | |
| } | |
| async function hydrateCacheFromDataset() { | |
| if (!HF_TOKEN) { | |
| return; | |
| } | |
| try { | |
| await fs.access(cachePath); | |
| updateCacheBackupStatus("local", "Using local cache; dataset backup is available for future writes."); | |
| return; | |
| } catch (error) { | |
| if (error.code !== "ENOENT") { | |
| throw error; | |
| } | |
| } | |
| try { | |
| await ensureHfDatasetAccess(); | |
| const remoteFile = await downloadFile({ | |
| repo: getHfRepo(), | |
| path: HF_CACHE_PATH, | |
| accessToken: HF_TOKEN | |
| }); | |
| if (!remoteFile) { | |
| updateCacheBackupStatus("empty", `No cache backup found in ${HF_DATASET_REPO}/${HF_CACHE_PATH}.`); | |
| return; | |
| } | |
| const text = await remoteFile.text(); | |
| const parsed = JSON.parse(text); | |
| if (!parsed.players || typeof parsed.players !== "object") { | |
| throw new Error("Remote cache backup does not contain a players object."); | |
| } | |
| await fs.mkdir(cacheDir, { recursive: true }); | |
| await fs.writeFile(cachePath, text.endsWith("\n") ? text : `${text}\n`); | |
| updateCacheBackupStatus("hydrated", `Hydrated cache from ${HF_DATASET_REPO}/${HF_CACHE_PATH}.`, { | |
| fetchedAt: new Date().toISOString() | |
| }); | |
| } catch (error) { | |
| console.error("Could not hydrate cache from Hugging Face dataset.", error); | |
| updateCacheBackupStatus( | |
| "hydrate_failed", | |
| `${getHfErrorMessage(error)} Using local cache only.` | |
| ); | |
| } | |
| } | |
| async function ensureCacheHydrated() { | |
| hydrateCachePromise ??= hydrateCacheFromDataset(); | |
| await hydrateCachePromise; | |
| } | |
| async function readCache() { | |
| await ensureCacheHydrated(); | |
| try { | |
| const text = await fs.readFile(cachePath, "utf8"); | |
| const parsed = JSON.parse(text); | |
| return { | |
| players: parsed.players && typeof parsed.players === "object" ? parsed.players : {} | |
| }; | |
| } catch (error) { | |
| if (error.code === "ENOENT") { | |
| return { players: {} }; | |
| } | |
| throw error; | |
| } | |
| } | |
| async function writeCache(cache) { | |
| await fs.mkdir(cacheDir, { recursive: true }); | |
| const serialized = `${JSON.stringify(cache, null, 2)}\n`; | |
| await fs.writeFile(cachePath, serialized); | |
| if (!HF_TOKEN) { | |
| return; | |
| } | |
| try { | |
| await ensureHfDatasetAccess(); | |
| await uploadFile({ | |
| repo: getHfRepo(), | |
| file: { | |
| path: HF_CACHE_PATH, | |
| content: new Blob([serialized], { type: "application/json" }) | |
| }, | |
| accessToken: HF_TOKEN, | |
| commitTitle: "Update DUPR player cache backup" | |
| }); | |
| updateCacheBackupStatus("pushed", `Backed up cache to ${HF_DATASET_REPO}/${HF_CACHE_PATH}.`, { | |
| pushedAt: new Date().toISOString() | |
| }); | |
| } catch (error) { | |
| console.error("Could not back up cache to Hugging Face dataset.", error); | |
| updateCacheBackupStatus( | |
| "push_failed", | |
| `${getHfErrorMessage(error)} Local cache was still written.` | |
| ); | |
| } | |
| } | |
| function walkJson(value) { | |
| if (Array.isArray(value)) { | |
| return value.flatMap(walkJson); | |
| } | |
| if (value && typeof value === "object") { | |
| return [value, ...Object.values(value).flatMap(walkJson)]; | |
| } | |
| return []; | |
| } | |
| function looksLikeNumericPlayerId(value) { | |
| return /^\d{8,}$/.test(String(value ?? "")); | |
| } | |
| function findPlayerId(data, query) { | |
| const normalizedQuery = String(query ?? "").trim().toLowerCase(); | |
| const idFields = ["id", "playerId", "player_id", "userId", "user_id", "duprId", "dupr_id"]; | |
| const candidates = walkJson(data).filter((item) => { | |
| try { | |
| return normalizedQuery && JSON.stringify(item).toLowerCase().includes(normalizedQuery); | |
| } catch { | |
| return false; | |
| } | |
| }); | |
| for (const candidate of candidates) { | |
| for (const field of idFields) { | |
| if (looksLikeNumericPlayerId(candidate[field])) { | |
| return { | |
| numericPlayerId: String(candidate[field]), | |
| matchedPlayer: candidate, | |
| candidates | |
| }; | |
| } | |
| } | |
| } | |
| for (const candidate of candidates) { | |
| for (const value of Object.values(candidate)) { | |
| if (looksLikeNumericPlayerId(value)) { | |
| return { | |
| numericPlayerId: String(value), | |
| matchedPlayer: candidate, | |
| candidates | |
| }; | |
| } | |
| } | |
| } | |
| return { | |
| numericPlayerId: null, | |
| matchedPlayer: candidates[0] ?? null, | |
| candidates | |
| }; | |
| } | |
| function apiError(status, errorCode, message) { | |
| return { | |
| status, | |
| body: { | |
| ok: false, | |
| errorCode, | |
| message | |
| } | |
| }; | |
| } | |
| async function resolveDuprPlayer({ | |
| query, | |
| lat = DEFAULT_LAT, | |
| lng = DEFAULT_LNG, | |
| includeUnclaimedPlayers = true | |
| }) { | |
| const normalizedQuery = String(query ?? "").trim(); | |
| const token = process.env.DUPR_BEARER_TOKEN; | |
| if (!normalizedQuery) { | |
| throw apiError(400, "empty_query", "Enter a DUPR ID or player code."); | |
| } | |
| if (!token) { | |
| throw apiError(500, "missing_token", "Missing DUPR_BEARER_TOKEN. Add it as a Hugging Face Space Secret."); | |
| } | |
| const payload = { | |
| limit: 10, | |
| offset: 0, | |
| query: normalizedQuery, | |
| exclude: [], | |
| includeUnclaimedPlayers, | |
| filter: { | |
| lat: Number.isFinite(lat) ? lat : DEFAULT_LAT, | |
| lng: Number.isFinite(lng) ? lng : DEFAULT_LNG, | |
| rating: { | |
| maxRating: null, | |
| minRating: null | |
| }, | |
| locationText: "" | |
| } | |
| }; | |
| let response; | |
| try { | |
| response = await fetch(DUPR_SEARCH_URL, { | |
| method: "POST", | |
| headers: getDuprHeaders(token), | |
| body: JSON.stringify(payload), | |
| signal: AbortSignal.timeout(20000) | |
| }); | |
| } catch (error) { | |
| const message = | |
| error?.name === "TimeoutError" | |
| ? "DUPR request timed out. Try again." | |
| : "Could not reach DUPR. Check the network and try again."; | |
| throw apiError(502, "network_error", message); | |
| } | |
| if (response.status === 401) { | |
| throw apiError(401, "invalid_token", "DUPR token appears expired or invalid. Refresh the token."); | |
| } | |
| if (response.status === 429) { | |
| throw apiError(429, "rate_limited", "DUPR rate limited the request. Try again later."); | |
| } | |
| if (!response.ok) { | |
| throw apiError(response.status, "dupr_api_error", `DUPR API returned HTTP ${response.status}.`); | |
| } | |
| let data; | |
| try { | |
| data = await response.json(); | |
| } catch { | |
| throw apiError(502, "non_json_response", "DUPR returned a response that was not JSON."); | |
| } | |
| const { numericPlayerId, matchedPlayer, candidates } = findPlayerId(data, normalizedQuery); | |
| if (!numericPlayerId) { | |
| const error = apiError( | |
| 404, | |
| candidates.length ? "missing_numeric_id" : "no_match", | |
| candidates.length | |
| ? "Found possible matches, but no numeric dashboard player id was obvious." | |
| : "No matching player was found." | |
| ); | |
| throw { | |
| ...error, | |
| body: { | |
| ...error.body, | |
| matchedPlayer, | |
| raw: data | |
| } | |
| }; | |
| } | |
| const dashboardUrl = `https://dashboard.dupr.com/dashboard/player/${numericPlayerId}`; | |
| const profileUrl = `https://dashboard.dupr.com/dashboard/player/${numericPlayerId}/profile`; | |
| return { | |
| ok: true, | |
| numericPlayerId, | |
| dashboardUrl, | |
| profileUrl, | |
| matchedPlayer, | |
| ratingBasics: extractRatingBasics(matchedPlayer), | |
| raw: data | |
| }; | |
| } | |
| async function readRoster() { | |
| const [text, cache] = await Promise.all([fs.readFile(rosterPath, "utf8"), readCache()]); | |
| const rows = parseCsv(text); | |
| const teams = rows.map((row, index) => { | |
| const level = String(row.LEVEL || "").trim(); | |
| const status = normalizeStatus(row.Status); | |
| const teamNumber = String(row.SN || index + 1).trim(); | |
| const players = [ | |
| { | |
| slot: 1, | |
| name: String(row["PLAYER 1"] || "").trim(), | |
| duprId: String(row["DUPR ID"] || "").trim() | |
| }, | |
| { | |
| slot: 2, | |
| name: String(row["PLAYER 2"] || "").trim(), | |
| duprId: String(row["DUPR ID_2"] || row["DUPR ID.1"] || "").trim() | |
| } | |
| ].map((player) => { | |
| const validDuprId = isValidDuprId(player.duprId); | |
| const cached = validDuprId ? cache.players[player.duprId] ?? null : null; | |
| const numericPlayerId = cached?.numericPlayerId ?? null; | |
| return { | |
| ...player, | |
| validDuprId, | |
| explorerUrl: buildExplorerUrl(numericPlayerId), | |
| cache: cached | |
| ? { | |
| status: cached.status, | |
| fetchedAt: cached.fetchedAt, | |
| numericPlayerId, | |
| dashboardUrl: cached.dashboardUrl ?? null, | |
| profileUrl: cached.profileUrl ?? null, | |
| ratingBasics: cached.ratingBasics ?? null, | |
| matchInfo: sanitizeMatchInfo(cached.matchInfo), | |
| message: cached.message ?? null, | |
| errorCode: cached.errorCode ?? null | |
| } | |
| : null | |
| }; | |
| }); | |
| return { | |
| id: `${teamNumber}-${index + 1}`, | |
| rowIndex: index + 1, | |
| teamNumber, | |
| level, | |
| status, | |
| complete: players.every((player) => player.name && player.validDuprId), | |
| players | |
| }; | |
| }); | |
| const allPlayers = teams.flatMap((team) => team.players); | |
| const validPlayers = allPlayers.filter((player) => player.validDuprId); | |
| const enrichedPlayers = validPlayers.filter((player) => player.cache?.status === "success"); | |
| const attemptedPlayers = validPlayers.filter((player) => player.cache); | |
| const countBy = (items, getter) => | |
| items.reduce((counts, item) => { | |
| const key = getter(item); | |
| counts[key] = (counts[key] ?? 0) + 1; | |
| return counts; | |
| }, {}); | |
| return { | |
| teams, | |
| stats: { | |
| totalTeams: teams.length, | |
| completeTeams: teams.filter((team) => team.complete).length, | |
| incompleteTeams: teams.filter((team) => !team.complete).length, | |
| totalPlayers: allPlayers.filter((player) => player.name).length, | |
| validDuprIds: validPlayers.length, | |
| enrichedPlayers: enrichedPlayers.length, | |
| unresolvedPlayers: attemptedPlayers.filter((player) => player.cache?.status === "error").length, | |
| levels: countBy(teams, (team) => team.level), | |
| statuses: countBy(teams, (team) => team.status) | |
| }, | |
| cache: { | |
| cachedPlayers: attemptedPlayers.length, | |
| explorerUrlPattern: EXPLORER_URL_PATTERN, | |
| backup: { | |
| repo: HF_DATASET_REPO, | |
| path: HF_CACHE_PATH, | |
| ...lastCacheBackup | |
| } | |
| } | |
| }; | |
| } | |
| async function refreshPlayer(duprId) { | |
| const normalizedDuprId = String(duprId || "").trim(); | |
| const cache = await readCache(); | |
| if (!isValidDuprId(normalizedDuprId)) { | |
| const entry = { | |
| status: "error", | |
| fetchedAt: new Date().toISOString(), | |
| errorCode: "invalid_dupr_id", | |
| message: "This player does not have a valid DUPR ID." | |
| }; | |
| cache.players[normalizedDuprId || "missing"] = entry; | |
| await writeCache(cache); | |
| return entry; | |
| } | |
| try { | |
| const result = await resolveDuprPlayer({ query: normalizedDuprId }); | |
| const matchInfo = await fetchMatchInfo(result.numericPlayerId).catch((error) => ({ | |
| errorCode: error?.body?.errorCode || error?.errorCode || "match_info_failed", | |
| message: error?.body?.message || error?.message || "Could not load match information.", | |
| summary: null, | |
| recent: [], | |
| totalHistory: 0 | |
| })); | |
| const entry = { | |
| status: "success", | |
| fetchedAt: new Date().toISOString(), | |
| numericPlayerId: result.numericPlayerId, | |
| dashboardUrl: result.dashboardUrl, | |
| profileUrl: result.profileUrl, | |
| matchedPlayer: result.matchedPlayer, | |
| ratingBasics: result.ratingBasics, | |
| matchInfo, | |
| raw: result.raw | |
| }; | |
| cache.players[normalizedDuprId] = entry; | |
| await writeCache(cache); | |
| return entry; | |
| } catch (error) { | |
| const existingEntry = cache.players[normalizedDuprId]; | |
| const errorCode = error?.body?.errorCode || error?.errorCode || "refresh_failed"; | |
| if ( | |
| existingEntry?.status === "success" && | |
| ["invalid_token", "rate_limited", "network_error", "dupr_api_error"].includes(errorCode) | |
| ) { | |
| return existingEntry; | |
| } | |
| const entry = { | |
| status: "error", | |
| fetchedAt: new Date().toISOString(), | |
| errorCode, | |
| message: error?.body?.message || error?.message || "Could not refresh this player.", | |
| matchedPlayer: error?.body?.matchedPlayer ?? null, | |
| raw: error?.body?.raw ?? null | |
| }; | |
| cache.players[normalizedDuprId] = entry; | |
| await writeCache(cache); | |
| return entry; | |
| } | |
| } | |
| app.get("/api/health", (_req, res) => { | |
| res.json({ ok: true }); | |
| }); | |
| app.get("/api/roster", async (_req, res) => { | |
| try { | |
| res.json(await readRoster()); | |
| } catch (error) { | |
| console.error("Could not read roster CSV.", error); | |
| res.status(500).json({ | |
| ok: false, | |
| errorCode: "roster_read_failed", | |
| message: "Could not read the roster CSV." | |
| }); | |
| } | |
| }); | |
| app.get("/api/cache", async (_req, res) => { | |
| try { | |
| const cache = await readCache(); | |
| res.json({ | |
| players: Object.fromEntries( | |
| Object.entries(cache.players).map(([duprId, entry]) => [ | |
| duprId, | |
| { | |
| status: entry.status, | |
| fetchedAt: entry.fetchedAt, | |
| numericPlayerId: entry.numericPlayerId ?? null, | |
| message: entry.message ?? null | |
| } | |
| ]) | |
| ), | |
| backup: { | |
| repo: HF_DATASET_REPO, | |
| path: HF_CACHE_PATH, | |
| ...lastCacheBackup | |
| } | |
| }); | |
| } catch { | |
| res.status(500).json({ | |
| ok: false, | |
| errorCode: "cache_read_failed", | |
| message: "Could not read the player cache." | |
| }); | |
| } | |
| }); | |
| app.post("/api/players/:duprId/refresh", async (req, res) => { | |
| const entry = await refreshPlayer(req.params.duprId); | |
| res.status(entry.status === "success" ? 200 : 207).json(entry); | |
| }); | |
| app.post("/api/players/refresh", async (req, res) => { | |
| const roster = await readRoster(); | |
| const cache = await readCache(); | |
| const force = Boolean(req.body?.force); | |
| const duprIds = [ | |
| ...new Set( | |
| roster.teams | |
| .flatMap((team) => team.players) | |
| .filter((player) => player.validDuprId) | |
| .map((player) => player.duprId) | |
| ) | |
| ].filter((duprId) => force || cache.players[duprId]?.status !== "success" || !cache.players[duprId]?.matchInfo); | |
| const results = []; | |
| for (const duprId of duprIds) { | |
| results.push({ | |
| duprId, | |
| result: await refreshPlayer(duprId) | |
| }); | |
| } | |
| res.json({ | |
| ok: true, | |
| refreshed: results.length, | |
| results | |
| }); | |
| }); | |
| app.post("/api/resolve", async (req, res) => { | |
| try { | |
| const result = await resolveDuprPlayer({ | |
| query: req.body?.query, | |
| lat: Number(req.body?.lat ?? DEFAULT_LAT), | |
| lng: Number(req.body?.lng ?? DEFAULT_LNG), | |
| includeUnclaimedPlayers: Boolean(req.body?.includeUnclaimedPlayers ?? true) | |
| }); | |
| res.json(result); | |
| } catch (error) { | |
| const status = error?.status || 500; | |
| const body = error?.body || { | |
| ok: false, | |
| errorCode: "resolve_failed", | |
| message: "The resolver could not complete the request. Try again." | |
| }; | |
| res.status(status).json(body); | |
| } | |
| }); | |
| if (process.env.NODE_ENV === "production") { | |
| app.use(express.static(distPath)); | |
| app.get("*", (_req, res) => { | |
| res.sendFile(path.join(distPath, "index.html")); | |
| }); | |
| } | |
| app.listen(PORT, "0.0.0.0", () => { | |
| console.log(`DUPR roster dashboard listening on port ${PORT}`); | |
| }); | |