App / R /scores_functions.R
benresnic's picture
Upload 3 files
bc881b4 verified
Raw History Blame Contribute Delete
66.3 kB
sort_scoreboard_games <- function(games) { if (nrow(games) == 0) { return(games) } if (!("start_datetime_utc" %in% names(games))) { games$start_datetime_utc <- NA_character_ } if (!("start_time" %in% names(games))) { games$start_time <- NA_character_ } if (!("start_sort_key" %in% names(games))) { games$start_sort_key <- NA_real_ } if (!("start_time_minutes" %in% names(games))) { games$start_time_minutes <- NA_real_ } games %>% dplyr::mutate( sort_bucket = dplyr::case_when( status_category == "live" ~ 0L, status_category == "upcoming" ~ 1L, status_category == "final" ~ 2L, status_category == "canceled" ~ 3L, TRUE ~ 4L ), start_sort_abs = dplyr::coalesce( as.numeric(start_sort_key), vapply(start_datetime_utc, parse_start_datetime_numeric, numeric(1)) ), start_sort_time = dplyr::coalesce( as.numeric(start_time_minutes), vapply(start_time, parse_time_label_minutes, numeric(1)) ), start_sort_missing_abs = is.na(start_sort_abs), start_sort = dplyr::if_else(start_sort_missing_abs, start_sort_time, start_sort_abs), start_sort = ifelse(is.na(start_sort), Inf, start_sort) ) %>% dplyr::arrange(sort_bucket, start_sort_missing_abs, start_sort, away, home) %>% dplyr::select(-sort_bucket, -start_sort_abs, -start_sort_time, -start_sort_missing_abs, -start_sort) } cache_live_result <- function(key, expr, ttl_seconds) { cache_get_or_set( key = key, expr = expr, namespace = "live_data", ttl = ttl_seconds ) } fetch_espn_scoreboard <- function(game_date = Sys.Date(), limit = 200) { date_value <- as.Date(game_date) if (is.na(date_value)) { empty <- tibble::tibble() attr(empty, "fetch_error") <- "Invalid date." return(empty) } cache_key <- make_cache_key("espn_scoreboard", format(date_value, "%Y%m%d"), as.integer(limit)) ttl_seconds <- get_cache_ttl_seconds("WVU_APP_CACHE_TTL_SCORES", default_seconds = 120) cache_live_result(cache_key, { date_key <- format(date_value, "%Y%m%d") url <- sprintf( "https://site.api.espn.com/apis/site/v2/sports/baseball/college-baseball/scoreboard?dates=%s&limit=%d", date_key, as.integer(limit) ) payload <- tryCatch( jsonlite::fromJSON(url, simplifyVector = FALSE), error = function(e) { NULL } ) if (is.null(payload)) { empty <- tibble::tibble() attr(empty, "fetch_error") <- "Could not reach ESPN scoreboard API." return(empty) } events <- payload$events %||% list() if (length(events) == 0) { return(tibble::tibble()) } games <- purrr::map_dfr(events, parse_espn_score_event) if (nrow(games) == 0) { return(tibble::tibble()) } sort_scoreboard_games(games) }, ttl_seconds = ttl_seconds) } is_standings_entry <- function(entry) { is.list(entry) && (!is.null(entry$team) || !is.null(entry$stats) || !is.null(entry$records)) } collect_standings_groups <- function(node, inherited_conference = NA_character_) { groups <- list() if (!is.list(node) || length(node) == 0) { return(groups) } local_conference <- as.character( node$name %||% node$displayName %||% node$shortDisplayName %||% inherited_conference %||% NA_character_ ) entry_sets <- list( node$standings$entries %||% list(), node$entries %||% list() ) for (entries in entry_sets) { if (length(entries) == 0) { next } valid <- vapply(entries, is_standings_entry, logical(1)) if (any(valid)) { conference_name <- if (!is.na(local_conference) && nzchar(local_conference)) { local_conference } else { "Other" } groups[[length(groups) + 1]] <- list( conference = conference_name, entries = entries[valid] ) } } if (!is.null(names(node))) { for (name in names(node)) { child <- node[[name]] if (!is.list(child) || length(child) == 0 || identical(name, "entries")) { next } groups <- c(groups, collect_standings_groups(child, local_conference)) } } else { for (child in node) { if (!is.list(child) || length(child) == 0) { next } groups <- c(groups, collect_standings_groups(child, local_conference)) } } groups } fetch_espn_standings <- function(season = NULL, seasontype = 2L) { cache_key <- make_cache_key("espn_standings", season %||% "<NULL>", seasontype) ttl_seconds <- get_cache_ttl_seconds("WVU_APP_CACHE_TTL_STANDINGS", default_seconds = 300) cache_live_result(cache_key, { base_url <- "https://site.api.espn.com/apis/site/v2/sports/baseball/college-baseball/standings" query <- c( if (!is.null(season) && !is.na(season)) paste0("season=", as.integer(season)) else NULL, paste0("seasontype=", as.integer(seasontype)) ) url <- if (length(query) > 0) paste0(base_url, "?", paste(query, collapse = "&")) else base_url payload <- tryCatch( jsonlite::fromJSON(url, simplifyVector = FALSE), error = function(e) NULL ) if (is.null(payload)) { empty <- tibble::tibble() attr(empty, "fetch_error") <- "Could not reach ESPN standings API." return(empty) } groups <- collect_standings_groups(payload) standings <- purrr::map_dfr(groups, function(group) { entries <- group$entries %||% list() conf_name <- as.character(group$conference %||% "Other") purrr::map_dfr(seq_along(entries), function(i) { parse_espn_standings_entry(entries[[i]], conf_name, i) }) }) if (nrow(standings) == 0) { empty <- tibble::tibble() attr(empty, "fetch_error") <- "No standings found." return(empty) } team_lookup_conf <- vapply(standings$team, lookup_conference_from_team_name, character(1)) team_lookup_conf[!nzchar(team_lookup_conf)] <- NA_character_ standings %>% dplyr::mutate( conference = dplyr::coalesce(team_lookup_conf, conference, "Other"), team_key = vapply(team, normalize_team_name, character(1)), conf_key = vapply(conference, normalize_team_name, character(1)), rank_sort = dplyr::coalesce(as.numeric(rank), as.numeric(default_rank), Inf), wins_sort = dplyr::coalesce(as.numeric(overall_wins), -Inf), losses_sort = dplyr::coalesce(as.numeric(overall_losses), Inf) ) %>% dplyr::arrange(conf_key, team_key, rank_sort, dplyr::desc(wins_sort), losses_sort, team) %>% dplyr::group_by(conf_key, team_key) %>% dplyr::slice(1) %>% dplyr::ungroup() %>% dplyr::arrange(conference, rank_sort, dplyr::desc(wins_sort), losses_sort, team) %>% dplyr::group_by(conference) %>% dplyr::mutate(display_rank = dplyr::row_number()) %>% dplyr::ungroup() %>% dplyr::select(-team_lookup_conf, -team_key, -conf_key, -rank_sort, -wins_sort, -losses_sort) }, ttl_seconds = ttl_seconds) } extract_html_table_rows <- function(table_node) { row_nodes <- xml2::xml_find_all(table_node, ".//tr") if (length(row_nodes) == 0) { return(list()) } rows <- lapply(row_nodes, function(row_node) { cell_nodes <- xml2::xml_find_all(row_node, "./th|./td") if (length(cell_nodes) == 0) { return(character()) } values <- trimws(xml2::xml_text(cell_nodes, trim = TRUE)) values[is.na(values)] <- "" values }) rows[lengths(rows) > 0] } extract_html_table_data_frame <- function(table_node) { if (requireNamespace("rvest", quietly = TRUE)) { tbl <- tryCatch( rvest::html_table(table_node, fill = TRUE), error = function(e) NULL ) if (!is.null(tbl) && nrow(tbl) > 0 && ncol(tbl) > 0) { tbl <- as.data.frame(tbl, stringsAsFactors = FALSE) nm <- trimws(as.character(names(tbl))) nm[is.na(nm) | !nzchar(nm)] <- paste0("col", which(is.na(nm) | !nzchar(nm))) names(tbl) <- make.unique(nm) return(tbl) } } rows <- extract_html_table_rows(table_node) if (length(rows) == 0) { return(data.frame(stringsAsFactors = FALSE)) } has_header <- any(grepl("team|school|program|overall|record|conf|rank", tolower(rows[[1]]), perl = TRUE)) if (has_header) { header <- rows[[1]] body_rows <- rows[-1] } else { width <- max(lengths(rows)) header <- paste0("col", seq_len(width)) body_rows <- rows } if (length(body_rows) == 0) { return(data.frame(stringsAsFactors = FALSE)) } width <- max(c(length(header), lengths(body_rows))) header <- c(header, rep("", max(0, width - length(header)))) header <- trimws(header) blank_header <- !nzchar(header) | is.na(header) header[blank_header] <- paste0("col", which(blank_header)) header <- make.unique(header) matrix_data <- matrix(NA_character_, nrow = length(body_rows), ncol = width) for (i in seq_along(body_rows)) { values <- body_rows[[i]] if (length(values) > 0) { matrix_data[i, seq_along(values)] <- values } } tbl <- as.data.frame(matrix_data, stringsAsFactors = FALSE) names(tbl) <- header tbl } extract_d1_table_heading <- function(table_node) { candidates <- c( trimws(xml2::xml_text(xml2::xml_find_first(table_node, ".//caption"), trim = TRUE)), trimws(xml2::xml_text(xml2::xml_find_first(table_node, "preceding::*[self::h2 or self::h3 or self::h4 or self::h5][1]"), trim = TRUE)) ) candidates <- candidates[!is.na(candidates) & nzchar(candidates)] if (length(candidates) == 0) { return(NA_character_) } heading <- candidates[[1]] heading <- gsub("(?i)conference\\s+standings", "", heading, perl = TRUE) heading <- gsub("(?i)standings", "", heading, perl = TRUE) heading <- trimws(heading) if (!nzchar(heading)) { return(NA_character_) } heading } normalize_record_text <- function(values) { text <- trimws(as.character(values %||% "")) text[is.na(text)] <- "" text <- gsub("[\u2012\u2013\u2014\u2212]", "-", text, perl = TRUE) text <- gsub("\\s+", "", text, perl = TRUE) matches <- regmatches(text, regexec("([0-9]+)-([0-9]+)", text, perl = TRUE)) out <- rep(NA_character_, length(text)) for (i in seq_along(matches)) { if (length(matches[[i]]) >= 3) { out[[i]] <- paste0(matches[[i]][2], "-", matches[[i]][3]) } } out } extract_record_components <- function(record_values) { values <- normalize_record_text(record_values) wins <- rep(NA_integer_, length(values)) losses <- rep(NA_integer_, length(values)) for (i in seq_along(values)) { wl <- extract_wl_from_record(values[[i]]) wins[[i]] <- suppressWarnings(as.integer(wl[[1]])) losses[[i]] <- suppressWarnings(as.integer(wl[[2]])) } list(record = values, wins = wins, losses = losses) } extract_d1_logo_src <- function(node) { if (inherits(node, "xml_missing") || length(node) == 0) { return(NA_character_) } attr_names <- c("src", "data-src", "data-lazy-src", "data-original", "srcset", "data-srcset") for (attr_name in attr_names) { value <- trimws(as.character(xml2::xml_attr(node, attr_name))) if (is.na(value) || !nzchar(value)) { next } if (attr_name %in% c("srcset", "data-srcset")) { first_src <- strsplit(value, ",", fixed = TRUE)[[1]][1] value <- trimws(strsplit(first_src, "\\s+", perl = TRUE)[[1]][1]) } if (!nzchar(value)) { next } if (startsWith(value, "//")) { value <- paste0("https:", value) } else if (!grepl("^https?://", value, ignore.case = TRUE)) { base_url <- tryCatch(xml2::xml_url(node), error = function(e) NA_character_) if (!is.na(base_url) && nzchar(base_url)) { value <- tryCatch(xml2::url_absolute(value, base_url), error = function(e) value) } } if (nzchar(value)) { return(value) } } NA_character_ } extract_d1_team_text_from_row <- function(row_node) { cells <- xml2::xml_find_all(row_node, "./td") if (length(cells) == 0) { return(NA_character_) } texts <- trimws(xml2::xml_text(cells, trim = TRUE)) texts <- texts[!is.na(texts) & nzchar(texts)] if (length(texts) == 0) { return(NA_character_) } invalid_exact <- c( "w", "l", "pct", "gb", "strk", "streak", "team", "record", "rank", "rk", "overall", "conference", "conf", "wins", "losses" ) for (text in texts) { key <- normalize_standing_key(text) if (!nzchar(key) || key %in% invalid_exact) { next } if (!grepl("[A-Za-z]", text, perl = TRUE)) { next } if (grepl("^[0-9]+$", text, perl = TRUE)) { next } if (grepl("^[0-9]+\\s*[-\u2013\u2014]\\s*[0-9]+$", text, perl = TRUE)) { next } if (grepl("^\\.?[0-9]{3}$", text, perl = TRUE)) { next } cleaned <- gsub("^#?[0-9]+\\s+", "", text, perl = TRUE) cleaned <- gsub("\\s+\\([0-9]+\\)$", "", cleaned, perl = TRUE) cleaned <- trimws(cleaned) if (nzchar(cleaned)) { return(cleaned) } } NA_character_ } extract_d1_team_logo_map <- function(table_node) { row_nodes <- xml2::xml_find_all(table_node, ".//tr[td]") if (length(row_nodes) == 0) { return(tibble::tibble(team_key = character(), team_logo = character())) } rows <- lapply(row_nodes, function(row_node) { logo_node <- xml2::xml_find_first(row_node, ".//img") logo_url <- extract_d1_logo_src(logo_node) team_name <- extract_d1_team_text_from_row(row_node) team_key <- normalize_team_name(team_name) if (is.na(logo_url) || !nzchar(logo_url) || !nzchar(team_key)) { return(NULL) } tibble::tibble(team_key = team_key, team_logo = logo_url) }) logo_tbl <- dplyr::bind_rows(rows) if (nrow(logo_tbl) == 0) { return(tibble::tibble(team_key = character(), team_logo = character())) } logo_tbl %>% dplyr::filter(!is.na(team_logo), nzchar(team_logo)) %>% dplyr::group_by(team_key) %>% dplyr::summarise(team_logo = dplyr::first(team_logo), .groups = "drop") } promote_standings_subheader_row <- function(tbl) { if (nrow(tbl) == 0 || ncol(tbl) == 0) { return(tbl) } first_row <- vapply(tbl[1, , drop = FALSE], function(x) trimws(as.character(x[[1]])), character(1)) first_key <- normalize_standing_key(first_row) if (length(first_key) == 0) { return(tbl) } header_like <- grepl("^(team|school|program|w|l|pct|gb|overall|record|conference|conf|wins|losses)$", first_key, perl = TRUE) | grepl("(overall|conference|conf|wins|losses|record|pct|gb)", first_key, perl = TRUE) if (mean(header_like, na.rm = TRUE) < 0.4) { return(tbl) } new_names <- names(tbl) for (i in seq_along(new_names)) { sub <- trimws(as.character(first_row[[i]])) if (!is.na(sub) && nzchar(sub)) { base_key <- normalize_standing_key(new_names[[i]]) sub_key <- normalize_standing_key(sub) if (nzchar(sub_key) && !identical(base_key, sub_key)) { new_names[[i]] <- paste(new_names[[i]], sub) } } } names(tbl) <- make.unique(trimws(new_names)) tbl <- tbl[-1, , drop = FALSE] rownames(tbl) <- NULL tbl } parse_integer_column <- function(values) { text <- trimws(as.character(values %||% "")) text[is.na(text)] <- "" text <- gsub("[^0-9-]", "", text, perl = TRUE) text[!nzchar(text)] <- NA_character_ suppressWarnings(as.integer(text)) } extract_record_from_wl_columns <- function(tbl, w_col, l_col) { n <- nrow(tbl) if (is.na(w_col) || is.na(l_col) || w_col < 1 || l_col < 1 || w_col > ncol(tbl) || l_col > ncol(tbl)) { return(list(record = rep(NA_character_, n), wins = rep(NA_integer_, n), losses = rep(NA_integer_, n))) } wins <- parse_integer_column(tbl[[w_col]]) losses <- parse_integer_column(tbl[[l_col]]) record <- ifelse(!is.na(wins) & !is.na(losses), paste0(wins, "-", losses), NA_character_) list(record = record, wins = wins, losses = losses) } find_wl_pair <- function(col_keys, include_pattern = NULL, exclude_pattern = NULL) { idx <- seq_along(col_keys) if (!is.null(include_pattern) && nzchar(include_pattern)) { idx <- idx[grepl(include_pattern, col_keys[idx], perl = TRUE)] } if (!is.null(exclude_pattern) && nzchar(exclude_pattern)) { idx <- idx[!grepl(exclude_pattern, col_keys[idx], perl = TRUE)] } if (length(idx) == 0) { return(c(NA_integer_, NA_integer_)) } win_idx <- idx[grepl("(wins?|w)$", col_keys[idx], perl = TRUE)] loss_idx <- idx[grepl("(losses?|l)$", col_keys[idx], perl = TRUE)] if (length(win_idx) == 0 || length(loss_idx) == 0) { return(c(NA_integer_, NA_integer_)) } best <- c(NA_integer_, NA_integer_) best_score <- Inf for (w in win_idx) { for (l in loss_idx) { score <- abs(l - w) + ifelse(l < w, 0.5, 0) if (score < best_score) { best <- c(w, l) best_score <- score } } } best } find_adjacent_wl_pairs <- function(col_keys) { pairs <- list() if (length(col_keys) < 2) { return(pairs) } for (i in seq_len(length(col_keys) - 1)) { if (grepl("(wins?|w)$", col_keys[[i]], perl = TRUE) && grepl("(losses?|l)$", col_keys[[i + 1]], perl = TRUE)) { pairs[[length(pairs) + 1]] <- c(i, i + 1) } } pairs } is_missing_record_vector <- function(x) { all(is.na(x) | !nzchar(trimws(as.character(x)))) } find_standings_column <- function(col_keys, exact = character(), contains = character(), candidates = seq_along(col_keys), prefer = NULL) { if (length(candidates) == 0) { return(NA_integer_) } exact_norm <- normalize_standing_key(exact) contains_norm <- normalize_standing_key(contains) prefer_norm <- normalize_standing_key(prefer %||% character()) candidate_keys <- col_keys[candidates] exact_hits <- candidates[candidate_keys %in% exact_norm] if (length(exact_hits) > 0) { return(exact_hits[[1]]) } for (term in contains_norm) { idx <- candidates[grepl(term, candidate_keys, fixed = TRUE)] if (length(idx) > 0) { return(idx[[1]]) } } for (term in prefer_norm) { idx <- candidates[grepl(term, candidate_keys, fixed = TRUE)] if (length(idx) > 0) { return(idx[[1]]) } } NA_integer_ } parse_d1baseball_standings_table <- function(table_node, fallback_conference = NA_character_) { logo_map <- extract_d1_team_logo_map(table_node) tbl <- extract_html_table_data_frame(table_node) if (nrow(tbl) == 0 || ncol(tbl) == 0) { return(tibble::tibble()) } tbl <- promote_standings_subheader_row(tbl) if (nrow(tbl) == 0 || ncol(tbl) == 0) { return(tibble::tibble()) } col_keys <- normalize_standing_key(names(tbl)) record_score <- vapply(tbl, function(col) { values <- trimws(as.character(col)) values <- values[!is.na(values) & nzchar(values)] if (length(values) == 0) { return(0) } mean(grepl("^[0-9]+\\s*[-\u2013\u2014]\\s*[0-9]+", values, perl = TRUE)) }, numeric(1)) team_col <- find_standings_column( col_keys, exact = c("team", "school", "program"), contains = c("team", "school", "program") ) if (is.na(team_col)) { alpha_score <- vapply(tbl, function(col) { values <- as.character(col) values <- values[!is.na(values) & nzchar(trimws(values))] if (length(values) == 0) { return(0) } mean(grepl("[A-Za-z]", values, perl = TRUE)) }, numeric(1)) candidate <- which.max(alpha_score) if (length(candidate) == 0 || alpha_score[[candidate]] < 0.4) { return(tibble::tibble()) } team_col <- candidate } rank_col <- find_standings_column( col_keys, exact = c("rk", "rank", "pos"), contains = c("rank", "rk", "position", "pos") ) record_cols <- which(record_score >= 0.3) conf_record_col <- find_standings_column( col_keys, exact = c("conf", "conferencerecord", "leaguerecord"), contains = c("conf", "conference", "league"), candidates = record_cols ) overall_record_col <- find_standings_column( col_keys, exact = c("overall", "record", "overallrecord"), contains = c("overall", "record", "total"), candidates = setdiff(record_cols, conf_record_col) ) if (is.na(overall_record_col)) { overall_record_col <- find_standings_column( col_keys, exact = c("overall", "record", "overallrecord"), contains = c("overall", "record", "total") ) } conf_name_col <- find_standings_column( col_keys, exact = c("conference", "conferencename", "league", "leaguename"), contains = c("conference", "league") ) team <- trimws(as.character(tbl[[team_col]])) team <- gsub("^#?[0-9]+\\s+", "", team, perl = TRUE) team <- gsub("\\s+\\([0-9]+\\)$", "", team, perl = TRUE) team[!nzchar(team)] <- NA_character_ rank <- if (!is.na(rank_col)) { suppressWarnings(as.integer(gsub("[^0-9]", "", as.character(tbl[[rank_col]]), perl = TRUE))) } else { rep(NA_integer_, nrow(tbl)) } conf_parts <- if (!is.na(conf_record_col)) { extract_record_components(tbl[[conf_record_col]]) } else { list( record = rep(NA_character_, nrow(tbl)), wins = rep(NA_integer_, nrow(tbl)), losses = rep(NA_integer_, nrow(tbl)) ) } overall_parts <- if (!is.na(overall_record_col)) { extract_record_components(tbl[[overall_record_col]]) } else { list( record = rep(NA_character_, nrow(tbl)), wins = rep(NA_integer_, nrow(tbl)), losses = rep(NA_integer_, nrow(tbl)) ) } conf_wl_pair <- find_wl_pair(col_keys, include_pattern = "(conf|conference|league)") overall_wl_pair <- find_wl_pair(col_keys, include_pattern = "(overall|total)") adjacent_pairs <- find_adjacent_wl_pairs(col_keys) if ((is.na(conf_wl_pair[[1]]) || is.na(conf_wl_pair[[2]])) && length(adjacent_pairs) > 0) { conf_wl_pair <- adjacent_pairs[[1]] } if ((is.na(overall_wl_pair[[1]]) || is.na(overall_wl_pair[[2]])) && length(adjacent_pairs) > 1) { overall_wl_pair <- adjacent_pairs[[2]] } if (is_missing_record_vector(conf_parts$record)) { conf_from_wl <- extract_record_from_wl_columns(tbl, conf_wl_pair[[1]], conf_wl_pair[[2]]) if (!is_missing_record_vector(conf_from_wl$record)) { conf_parts <- conf_from_wl } } need_overall_wl <- is_missing_record_vector(overall_parts$record) if (!need_overall_wl && !is_missing_record_vector(conf_parts$record)) { same_record <- sum(!is.na(overall_parts$record) & !is.na(conf_parts$record) & overall_parts$record == conf_parts$record) compared <- sum(!is.na(overall_parts$record) & !is.na(conf_parts$record)) need_overall_wl <- compared > 0 && same_record == compared && length(adjacent_pairs) > 1 } if (need_overall_wl) { overall_from_wl <- extract_record_from_wl_columns(tbl, overall_wl_pair[[1]], overall_wl_pair[[2]]) if (!is_missing_record_vector(overall_from_wl$record) && !identical(overall_from_wl$record, conf_parts$record)) { overall_parts <- overall_from_wl } } row_conference <- if (!is.na(conf_name_col)) { trimws(as.character(tbl[[conf_name_col]])) } else { rep(as.character(fallback_conference), nrow(tbl)) } row_conference[!nzchar(row_conference)] <- NA_character_ out <- tibble::tibble( conference = row_conference, team = team, team_logo = NA_character_, rank = rank, default_rank = seq_len(nrow(tbl)), overall_record = overall_parts$record, conference_record = conf_parts$record, overall_wins = suppressWarnings(as.integer(overall_parts$wins)), overall_losses = suppressWarnings(as.integer(overall_parts$losses)), conference_wins = suppressWarnings(as.integer(conf_parts$wins)), conference_losses = suppressWarnings(as.integer(conf_parts$losses)) ) %>% dplyr::filter( !is.na(team), !normalize_standing_key(team) %in% c("team", "school", "program"), !is.na(overall_record) | !is.na(conference_record) ) if (nrow(out) > 0 && nrow(logo_map) > 0) { out <- out %>% dplyr::mutate(team_key = vapply(team, normalize_team_name, character(1))) %>% dplyr::left_join(logo_map, by = "team_key", suffix = c("", "_mapped")) %>% dplyr::mutate(team_logo = dplyr::coalesce(team_logo_mapped, team_logo)) %>% dplyr::select(-team_key, -team_logo_mapped) } out } fetch_d1baseball_standings <- function(season = NULL) { cache_key <- make_cache_key("d1baseball_standings", season %||% "<NULL>") ttl_seconds <- get_cache_ttl_seconds("WVU_APP_CACHE_TTL_STANDINGS", default_seconds = 300) cache_live_result(cache_key, { if (!requireNamespace("xml2", quietly = TRUE)) { empty <- tibble::tibble() attr(empty, "fetch_error") <- "xml2 package is required for D1Baseball standings." return(empty) } normalize_conference_slug <- function(value) { slug <- tolower(trimws(as.character(value %||% ""))) slug <- iconv(slug, to = "ASCII//TRANSLIT", sub = "") slug[is.na(slug)] <- "" slug <- gsub("&", " and ", slug, fixed = TRUE) slug <- gsub("[^a-z0-9]+", "-", slug, perl = TRUE) slug <- gsub("(^-|-$)", "", slug, perl = TRUE) slug } conference_slug_candidates <- function(conference_name) { conf_key <- normalize_standing_key(conference_name) base_slug <- normalize_conference_slug(conference_name) if (!nzchar(base_slug)) { return(character()) } explicit <- switch( conf_key, "acc" = c("acc", "atlantic-coast-conference"), "atlanticcoast" = c("acc", "atlantic-coast-conference"), "americaeast" = c("america-east-conference", "america-east"), "americanathletic" = c("american-athletic-conference", "american-athletic", "aac"), "atlanticsun" = c("atlantic-sun-conference", "atlantic-sun", "asun"), "atlantic10" = c("atlantic-10-conference", "atlantic-10", "a10"), "big12" = c("big-12-conference", "big-12"), "bigeast" = c("big-east-conference", "big-east"), "bigsouth" = c("big-south-conference", "big-south"), "bigten" = c("big-ten-conference", "big-ten"), "bigwest" = c("big-west-conference", "big-west"), "colonialathleticassociation" = c("colonial-athletic-association", "caa"), "conferenceusa" = c("conference-usa", "cusa"), "horizonleague" = c("horizon-league"), "ivyleague" = c("ivy-league"), "metroatlanticathletic" = c("metro-atlantic-athletic-conference", "maac"), "midamerican" = c("mid-american-conference", "mac"), "mideasternathletic" = c("mid-eastern-athletic-conference", "meac"), "missourivalley" = c("missouri-valley-conference", "mvc"), "mountainwest" = c("mountain-west-conference", "mwc"), "ncaadivisioniindependents" = c("ncaa-division-i-independents", "division-i-independents", "independents"), "northeast" = c("northeast-conference", "nec"), "ohiovalley" = c("ohio-valley-conference", "ovc"), "pac12" = c("pac-12-conference", "pac-12"), "pacific10" = c("pac-10-conference", "pac-10"), "patriotleague" = c("patriot-league"), "sec" = c("southeastern-conference", "sec"), "southeastern" = c("southeastern-conference", "sec"), "southern" = c("southern-conference", "socon"), "southland" = c("southland-conference"), "southwesternathletic" = c("southwestern-athletic-conference", "swac"), "summitleague" = c("summit-league"), "sunbelt" = c("sun-belt-conference", "sun-belt"), "westcoast" = c("west-coast-conference", "wcc"), "westernathletic" = c("western-athletic-conference", "wac"), NULL ) unique(c( explicit, paste0(base_slug, "-conference"), base_slug )) } read_d1_doc <- function(url, ua) { out <- list(doc = NULL, reached = FALSE, error = NULL) result <- tryCatch( { if (requireNamespace("httr", quietly = TRUE)) { response <- httr::GET(url, httr::user_agent(ua), httr::timeout(20)) if (httr::http_error(response)) { out$reached <- TRUE out } else { html_text <- httr::content(response, as = "text", encoding = "UTF-8") out$doc <- xml2::read_html(html_text) out$reached <- TRUE out } } else { out$doc <- xml2::read_html(url) out$reached <- TRUE out } }, error = function(e) { out$error <- conditionMessage(e) out } ) result } season_year <- suppressWarnings(as.integer(season)) if (length(season_year) == 0 || is.na(season_year)) { season_year <- suppressWarnings(as.integer(format(Sys.Date(), "%Y"))) } user_agent_text <- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" standings_by_conference <- list() reached_source <- FALSE parse_error <- NULL for (conference_name in get_all_conferences()) { slug_candidates <- conference_slug_candidates(conference_name) if (length(slug_candidates) == 0) { next } conf_tbl <- tibble::tibble() for (slug in slug_candidates) { conf_url <- sprintf("https://d1baseball.com/conference/%s/%d/", slug, season_year) fetch_result <- read_d1_doc(conf_url, user_agent_text) reached_source <- reached_source || isTRUE(fetch_result$reached) if (!is.null(fetch_result$error) && nzchar(fetch_result$error)) { parse_error <- fetch_result$error } if (is.null(fetch_result$doc)) { next } tables <- xml2::xml_find_all(fetch_result$doc, ".//table") if (length(tables) == 0) { next } conf_tbl <- purrr::map_dfr(seq_along(tables), function(i) { table_node <- tables[[i]] table_conf <- extract_d1_table_heading(table_node) fallback_conf <- if (!is.na(table_conf) && nzchar(table_conf)) table_conf else conference_name parse_d1baseball_standings_table(table_node, fallback_conference = fallback_conf) }) if (nrow(conf_tbl) > 0) { break } } if (nrow(conf_tbl) > 0) { standings_by_conference[[length(standings_by_conference) + 1]] <- conf_tbl } } standings <- dplyr::bind_rows(standings_by_conference) if (nrow(standings) == 0) { for (url in c("https://d1baseball.com/standings/", "https://www.d1baseball.com/standings/")) { fetch_result <- read_d1_doc(url, user_agent_text) reached_source <- reached_source || isTRUE(fetch_result$reached) if (!is.null(fetch_result$error) && nzchar(fetch_result$error)) { parse_error <- fetch_result$error } if (is.null(fetch_result$doc)) { next } tables <- xml2::xml_find_all(fetch_result$doc, ".//table") if (length(tables) == 0) { next } standings <- purrr::map_dfr(seq_along(tables), function(i) { table_node <- tables[[i]] table_conf <- extract_d1_table_heading(table_node) parse_d1baseball_standings_table(table_node, fallback_conference = table_conf) }) if (nrow(standings) > 0) { break } } } if (nrow(standings) > 0) { team_lookup_conf <- vapply(standings$team, lookup_conference_from_team_name, character(1)) team_lookup_conf[!nzchar(team_lookup_conf)] <- NA_character_ standings <- standings %>% dplyr::mutate( conference = trimws(as.character(conference)), conference = dplyr::if_else(is.na(conference) | !nzchar(conference), NA_character_, conference), conference = dplyr::coalesce(team_lookup_conf, conference, "Other"), overall_record = dplyr::if_else(is.na(overall_record) | !nzchar(overall_record), "-", overall_record), conference_record = dplyr::if_else(is.na(conference_record) | !nzchar(conference_record), "-", conference_record), team_key = vapply(team, normalize_team_name, character(1)), conf_key = vapply(conference, normalize_team_name, character(1)), rank_sort = dplyr::coalesce(as.numeric(rank), as.numeric(default_rank), Inf), wins_sort = dplyr::coalesce(as.numeric(overall_wins), -Inf), losses_sort = dplyr::coalesce(as.numeric(overall_losses), Inf) ) %>% dplyr::arrange(conf_key, team_key, rank_sort, dplyr::desc(wins_sort), losses_sort, team) %>% dplyr::group_by(conf_key, team_key) %>% dplyr::slice(1) %>% dplyr::ungroup() %>% dplyr::arrange(conference, rank_sort, dplyr::desc(wins_sort), losses_sort, team) %>% dplyr::group_by(conference) %>% dplyr::mutate(display_rank = dplyr::row_number()) %>% dplyr::ungroup() %>% dplyr::select( conference, team, team_logo, rank, default_rank, overall_record, conference_record, overall_wins, overall_losses, conference_wins, conference_losses, display_rank ) return(standings) } empty <- tibble::tibble() if (!reached_source) { attr(empty, "fetch_error") <- "Could not reach D1Baseball standings." } else { attr(empty, "fetch_error") <- "No standings found." } if (!is.null(parse_error) && nzchar(parse_error)) { attr(empty, "fetch_error_detail") <- parse_error } empty }, ttl_seconds = ttl_seconds) } fetch_local_standings <- function(season = NULL) { cache_key <- make_cache_key("local_standings", season %||% "<NULL>") ttl_seconds <- get_cache_ttl_seconds("WVU_APP_CACHE_TTL_STANDINGS", default_seconds = 300) cache_live_result(cache_key, { pbp_path <- resolve_scores_file("ncaa_pbp.parquet") if (is.na(pbp_path)) { empty <- tibble::tibble() attr(empty, "fetch_error") <- "No standings found." return(empty) } if (!requireNamespace("arrow", quietly = TRUE)) { empty <- tibble::tibble() attr(empty, "fetch_error") <- "No standings found." return(empty) } pbp <- tryCatch( arrow::read_parquet(pbp_path), error = function(e) NULL ) if (is.null(pbp) || nrow(pbp) == 0) { empty <- tibble::tibble() attr(empty, "fetch_error") <- "No standings found." return(empty) } required_cols <- c("game_id", "game_date", "away_team", "home_team", "away_score_after", "home_score_after") if (!all(required_cols %in% names(pbp))) { empty <- tibble::tibble() attr(empty, "fetch_error") <- "No standings found." return(empty) } games <- pbp %>% dplyr::select(dplyr::all_of(required_cols)) %>% dplyr::mutate(game_date = as.character(game_date)) season_year <- suppressWarnings(as.integer(season)) if (length(season_year) == 0) { season_year <- NA_integer_ } if (!is.na(season_year)) { games <- games %>% dplyr::filter(substr(game_date, 1, 4) == as.character(season_year)) } else if (nrow(games) == 0) { all_years <- suppressWarnings(as.integer(substr(as.character(pbp$game_date), 1, 4))) all_years <- all_years[!is.na(all_years)] latest_year <- if (length(all_years) > 0) max(all_years) else NA_integer_ if (!is.na(latest_year)) { games <- pbp %>% dplyr::select(dplyr::all_of(required_cols)) %>% dplyr::mutate(game_date = as.character(game_date)) %>% dplyr::filter(substr(game_date, 1, 4) == as.character(latest_year)) } } if (nrow(games) == 0) { empty <- tibble::tibble() attr(empty, "fetch_error") <- "No standings found." return(empty) } finals <- games %>% dplyr::group_by(game_id, game_date, away_team, home_team) %>% dplyr::summarise( away_final = suppressWarnings(max(as.numeric(away_score_after), na.rm = TRUE)), home_final = suppressWarnings(max(as.numeric(home_score_after), na.rm = TRUE)), .groups = "drop" ) %>% dplyr::filter(is.finite(away_final), is.finite(home_final), away_final != home_final) if (nrow(finals) == 0) { empty <- tibble::tibble() attr(empty, "fetch_error") <- "No standings found." return(empty) } finals <- finals %>% dplyr::mutate( away_conference = vapply(away_team, lookup_conference_from_team_name, character(1)), home_conference = vapply(home_team, lookup_conference_from_team_name, character(1)) ) away_rows <- finals %>% dplyr::transmute( team = away_team, conference = away_conference, overall_wins = as.integer(away_final > home_final), overall_losses = as.integer(away_final < home_final), conference_wins = as.integer(!is.na(away_conference) & !is.na(home_conference) & nzchar(away_conference) & nzchar(home_conference) & away_conference == home_conference & away_final > home_final), conference_losses = as.integer(!is.na(away_conference) & !is.na(home_conference) & nzchar(away_conference) & nzchar(home_conference) & away_conference == home_conference & away_final < home_final) ) home_rows <- finals %>% dplyr::transmute( team = home_team, conference = home_conference, overall_wins = as.integer(home_final > away_final), overall_losses = as.integer(home_final < away_final), conference_wins = as.integer(!is.na(home_conference) & !is.na(away_conference) & nzchar(home_conference) & nzchar(away_conference) & home_conference == away_conference & home_final > away_final), conference_losses = as.integer(!is.na(home_conference) & !is.na(away_conference) & nzchar(home_conference) & nzchar(away_conference) & home_conference == away_conference & home_final < away_final) ) standings <- dplyr::bind_rows(away_rows, home_rows) %>% dplyr::mutate( conference = dplyr::if_else(is.na(conference) | !nzchar(conference), "Other", conference) ) %>% dplyr::group_by(conference, team) %>% dplyr::summarise( overall_wins = sum(overall_wins, na.rm = TRUE), overall_losses = sum(overall_losses, na.rm = TRUE), conference_wins = sum(conference_wins, na.rm = TRUE), conference_losses = sum(conference_losses, na.rm = TRUE), .groups = "drop" ) %>% dplyr::mutate( team_logo = NA_character_, rank = NA_integer_, default_rank = NA_integer_, overall_record = paste0(overall_wins, "-", overall_losses), conference_record = paste0(conference_wins, "-", conference_losses), rank_sort = Inf, wins_sort = as.numeric(overall_wins), losses_sort = as.numeric(overall_losses) ) %>% dplyr::arrange(conference, dplyr::desc(wins_sort), losses_sort, team) %>% dplyr::group_by(conference) %>% dplyr::mutate(display_rank = dplyr::row_number()) %>% dplyr::ungroup() %>% dplyr::select( conference, team, team_logo, rank, default_rank, overall_record, conference_record, overall_wins, overall_losses, conference_wins, conference_losses, display_rank ) if (nrow(standings) == 0) { empty <- tibble::tibble() attr(empty, "fetch_error") <- "No standings found." return(empty) } standings }, ttl_seconds = ttl_seconds) } normalize_standing_key <- function(value) { text <- tolower(trimws(as.character(value %||% ""))) text[is.na(text)] <- "" gsub("[^a-z0-9]+", "", text) } extract_standing_stat <- function(stats, keys) { if (length(stats) == 0) { return(list(value = NA_character_, numeric = NA_real_)) } norm_keys <- unique(normalize_standing_key(keys)) for (stat in stats) { candidates <- c( stat$name, stat$displayName, stat$shortDisplayName, stat$abbreviation, stat$description, stat$type ) norm_candidates <- unique(normalize_standing_key(candidates)) if (any(norm_candidates %in% norm_keys)) { value <- as.character(stat$displayValue %||% stat$summary %||% stat$value %||% NA_character_) num <- suppressWarnings(as.numeric(stat$value %||% stat$displayValue %||% NA_real_)) return(list(value = value, numeric = num)) } } list(value = NA_character_, numeric = NA_real_) } extract_record_summary <- function(records, key_patterns) { if (length(records) == 0) { return(NA_character_) } for (rec in records) { rec_name <- tolower(trimws(as.character(rec$name %||% rec$type %||% rec$description %||% ""))) if (!nzchar(rec_name)) { next } if (any(vapply(key_patterns, function(p) grepl(p, rec_name), logical(1)))) { summary <- as.character(rec$summary %||% NA_character_) if (!is.na(summary) && nzchar(summary)) { return(summary) } wins <- suppressWarnings(as.integer(rec$wins %||% NA_integer_)) losses <- suppressWarnings(as.integer(rec$losses %||% NA_integer_)) ties <- suppressWarnings(as.integer(rec$ties %||% NA_integer_)) if (!is.na(wins) && !is.na(losses)) { if (!is.na(ties) && ties > 0) { return(sprintf("%d-%d-%d", wins, losses, ties)) } return(sprintf("%d-%d", wins, losses)) } } } NA_character_ } extract_wl_from_record <- function(record_text) { text <- trimws(as.character(record_text %||% "")) if (!nzchar(text)) { return(c(NA_integer_, NA_integer_)) } parts <- regmatches(text, regexec("^([0-9]+)-([0-9]+)", text, perl = TRUE))[[1]] if (length(parts) < 3) { return(c(NA_integer_, NA_integer_)) } c(suppressWarnings(as.integer(parts[2])), suppressWarnings(as.integer(parts[3]))) } extract_team_logo_from_standing <- function(team) { first_logo <- NULL if (is.list(team$logos) && length(team$logos) > 0) { first_logo <- team$logos[[1]]$href %||% team$logos[[1]]$url } candidates <- list( first_logo, team$logo ) for (candidate in candidates) { if (is.null(candidate) || length(candidate) == 0) { next } value <- trimws(as.character(candidate[[1]])) if (!is.na(value) && nzchar(value)) { return(value) } } NA_character_ } parse_espn_standings_entry <- function(entry, conference_name, default_rank = NA_integer_) { team_obj <- entry$team %||% list() stats <- entry$stats %||% list() records <- entry$records %||% list() overall_record <- extract_record_summary(records, c("overall")) conference_record <- extract_record_summary(records, c("conference", "conf", "league")) if (is.na(overall_record)) { overall_record <- extract_standing_stat(stats, c("overall", "record", "overallrecord"))$value } if (is.na(conference_record)) { conference_record <- extract_standing_stat(stats, c("conference", "conf", "leaguerecord", "conferencerecord"))$value } wins <- extract_standing_stat(stats, c("wins", "w"))$numeric losses <- extract_standing_stat(stats, c("losses", "l"))$numeric if (is.na(wins) || is.na(losses)) { wl <- extract_wl_from_record(overall_record) if (is.na(wins)) { wins <- wl[1] } if (is.na(losses)) { losses <- wl[2] } } conf_wins <- extract_standing_stat(stats, c("conferencewins", "confwins"))$numeric conf_losses <- extract_standing_stat(stats, c("conferencelosses", "conflosses"))$numeric if (is.na(conf_wins) || is.na(conf_losses)) { cwl <- extract_wl_from_record(conference_record) if (is.na(conf_wins)) { conf_wins <- cwl[1] } if (is.na(conf_losses)) { conf_losses <- cwl[2] } } rank_value <- suppressWarnings(as.integer( entry$rank %||% extract_standing_stat(stats, c("rank", "seed", "playoffseed"))$numeric %||% NA_integer_ )) tibble::tibble( conference = as.character(conference_name %||% "Other"), team = as.character(team_obj$displayName %||% team_obj$shortDisplayName %||% team_obj$name %||% "Team"), team_logo = as.character(extract_team_logo_from_standing(team_obj)), rank = rank_value, default_rank = as.integer(default_rank), overall_record = as.character(overall_record %||% "-"), conference_record = as.character(conference_record %||% "-"), overall_wins = suppressWarnings(as.integer(wins)), overall_losses = suppressWarnings(as.integer(losses)), conference_wins = suppressWarnings(as.integer(conf_wins)), conference_losses = suppressWarnings(as.integer(conf_losses)) ) } extract_competitor_rank <- function(competitor) { candidates <- list( competitor$curatedRank$current, competitor$curatedRank$currentRank, competitor$curatedRank$rank, competitor$curatedRank, competitor$rank, competitor$team$curatedRank$current, competitor$team$curatedRank$currentRank, competitor$team$curatedRank$rank, competitor$team$rank ) for (candidate in candidates) { if (is.null(candidate) || length(candidate) == 0) { next } value <- suppressWarnings(as.integer(candidate[[1]])) if (!is.na(value) && value > 0) { return(value) } } NA_integer_ } extract_bool <- function(value) { if (is.null(value) || length(value) == 0) { return(NA) } first <- value[[1]] bool <- suppressWarnings(as.logical(first)) if (!is.na(bool)) { return(bool) } text <- tolower(as.character(first)) if (text %in% c("true", "t", "1", "yes", "y")) { return(TRUE) } if (text %in% c("false", "f", "0", "no", "n")) { return(FALSE) } NA } resolve_scores_file <- function(filename) { candidates <- c( file.path("Data", filename), file.path("..", "Data", filename), filename ) existing <- candidates[file.exists(candidates)] if (length(existing) == 0) { return(NA_character_) } existing[[1]] } HF_REPO_ID <- "WVUBaseball/Data" hf_read <- function( repo_id, path, type = c("parquet", "csv", "rds", "rdata", "raw"), token_env = "api_key", rev = "main" ) { type <- match.arg(type) url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/", rev, "/", path) token <- Sys.getenv(token_env, unset = "") headers <- if (nzchar(token)) { httr::add_headers(Authorization = paste("Bearer", token)) } else { NULL } resp <- if (is.null(headers)) { httr::GET(url) } else { httr::GET(url, headers) } if (httr::status_code(resp) != 200) { stop(sprintf("HF download failed (%s): %s", httr::status_code(resp), url), call. = FALSE) } ext <- switch( type, parquet = ".parquet", csv = ".csv", rds = ".rds", rdata = ".RData", raw = "" ) tmp <- tempfile(fileext = ext) writeBin(httr::content(resp, "raw"), tmp) switch( type, parquet = arrow::read_parquet(tmp) |> tibble::as_tibble(), csv = readr::read_csv( tmp, show_col_types = FALSE, locale = readr::locale(encoding = "UTF-8") ), rds = readRDS(tmp), rdata = { e <- new.env(parent = emptyenv()) load(tmp, envir = e) e }, raw = readBin(tmp, what = "raw", n = file.info(tmp)$size) ) } read_scores_csv <- function(filename) { remote <- tryCatch( hf_read(HF_REPO_ID, filename, type = "csv"), error = function(e) NULL ) if (!is.null(remote)) { return(as.data.frame(remote, stringsAsFactors = FALSE)) } local_path <- resolve_scores_file(filename) if (is.na(local_path)) { return(data.frame(stringsAsFactors = FALSE)) } tryCatch( utils::read.csv(local_path, stringsAsFactors = FALSE), error = function(e) data.frame(stringsAsFactors = FALSE) ) } get_all_conferences <- local({ cache <- NULL function() { if (!is.null(cache)) { return(cache) } conferences <- character() conf_tbl <- read_scores_csv("conferences.csv") if ("conference" %in% names(conf_tbl)) { conferences <- c(conferences, as.character(conf_tbl$conference)) } teams_tbl <- read_scores_csv("teams.csv") if ("conference" %in% names(teams_tbl)) { conferences <- c(conferences, as.character(teams_tbl$conference)) } conferences <- trimws(conferences) conferences <- conferences[!is.na(conferences) & nzchar(conferences)] conferences <- sort(unique(conferences)) cache <<- conferences conferences } }) normalize_team_name <- function(value) { text <- trimws(as.character(value %||% "")) if (!nzchar(text)) { return("") } text <- tolower(text) text <- gsub("&", " and ", text, fixed = TRUE) text <- gsub("[^a-z0-9]+", " ", text) text <- gsub("\\s+", " ", text) trimws(text) } build_conference_lookup <- function() { teams <- read_scores_csv("teams.csv") conv <- read_scores_csv("team_conversions.csv") if (!all(c("conference", "team_name") %in% names(teams))) { return(tibble::tibble(alias_key = character(), conference = character())) } if ("BRef" %in% names(conv)) { keep_cols <- intersect(c("BRef", "NCAA", "D1Baseball"), names(conv)) base <- teams %>% dplyr::select(conference, team_name) %>% dplyr::left_join(conv %>% dplyr::select(dplyr::all_of(keep_cols)), by = c("team_name" = "BRef")) } else { base <- teams %>% dplyr::select(conference, team_name) base$NCAA <- NA_character_ base$D1Baseball <- NA_character_ } alias_tbl <- dplyr::bind_rows( tibble::tibble(conference = base$conference, alias = base$team_name), tibble::tibble(conference = base$conference, alias = base$NCAA), tibble::tibble(conference = base$conference, alias = base$D1Baseball) ) %>% dplyr::mutate( conference = trimws(as.character(conference)), alias_key = vapply(alias, normalize_team_name, character(1)) ) %>% dplyr::filter(!is.na(conference), nzchar(conference), nzchar(alias_key)) %>% dplyr::distinct(alias_key, conference) if (nrow(alias_tbl) == 0) { return(tibble::tibble(alias_key = character(), conference = character())) } ambiguous <- alias_tbl %>% dplyr::count(alias_key, name = "n_conferences") %>% dplyr::filter(n_conferences > 1) if (nrow(ambiguous) > 0) { alias_tbl <- alias_tbl %>% dplyr::filter(!(alias_key %in% ambiguous$alias_key)) } alias_tbl %>% dplyr::distinct(alias_key, conference) } get_conference_lookup <- local({ cache <- NULL function() { if (is.null(cache)) { cache <<- build_conference_lookup() } cache } }) lookup_conference_from_team <- function(competitor) { lookup <- get_conference_lookup() if (nrow(lookup) == 0) { return(NA_character_) } team <- competitor$team %||% list() candidates <- c( as.character(team$displayName %||% NA_character_), as.character(team$shortDisplayName %||% NA_character_), as.character(team$name %||% NA_character_), as.character(team$location %||% NA_character_), if (!is.null(team$location) && !is.null(team$name)) { as.character(paste(team$location, team$name)) } else { NA_character_ } ) for (candidate in candidates) { key <- normalize_team_name(candidate) if (!nzchar(key)) { next } matched <- lookup %>% dplyr::filter(alias_key == key) if (nrow(matched) > 0) { value <- trimws(as.character(matched$conference[[1]])) if (!is.na(value) && nzchar(value)) { return(value) } } } NA_character_ } lookup_conference_from_team_name <- function(team_name) { lookup <- get_conference_lookup() if (nrow(lookup) == 0) { return(NA_character_) } key <- normalize_team_name(team_name) if (!nzchar(key)) { return(NA_character_) } matched <- lookup %>% dplyr::filter(alias_key == key) if (nrow(matched) == 0) { return(NA_character_) } value <- trimws(as.character(matched$conference[[1]])) if (is.na(value) || !nzchar(value)) { return(NA_character_) } value } extract_conference_name <- function(competitor) { lookup_value <- lookup_conference_from_team(competitor) if (!is.na(lookup_value) && nzchar(lookup_value)) { return(lookup_value) } candidates <- list( competitor$team$conferenceName, competitor$team$conferenceShortName, competitor$team$conference$displayName, competitor$team$conference$name, competitor$team$conference$shortDisplayName, competitor$team$conference$shortName, competitor$team$groups[[1]]$displayName, competitor$team$groups[[1]]$name, competitor$team$groups[[1]]$shortName, competitor$team$group$displayName, competitor$team$group$name, competitor$team$group$shortName, competitor$conference$displayName, competitor$conference$name, competitor$conference$shortDisplayName, competitor$conference$shortName, competitor$groups[[1]]$displayName, competitor$groups[[1]]$name, competitor$groups[[1]]$shortName ) for (candidate in candidates) { if (is.null(candidate) || length(candidate) == 0) { next } value <- trimws(as.character(candidate[[1]])) if (!is.na(value) && nzchar(value)) { return(value) } } NA_character_ } extract_logo_url <- function(competitor) { team_id <- as.character(competitor$team$id %||% NA_character_) team_logo_by_id <- if (!is.na(team_id) && nzchar(team_id)) { sprintf("https://a.espncdn.com/i/teamlogos/ncaa/500/%s.png", team_id) } else { NA_character_ } first_logo <- NULL if (is.list(competitor$team$logos) && length(competitor$team$logos) > 0) { first_logo <- competitor$team$logos[[1]]$href %||% competitor$team$logos[[1]]$url } alt_logo <- NULL if (is.list(competitor$logos) && length(competitor$logos) > 0) { alt_logo <- competitor$logos[[1]]$href %||% competitor$logos[[1]]$url } candidates <- list( first_logo, competitor$team$logo, alt_logo, competitor$logo, team_logo_by_id ) for (candidate in candidates) { if (is.null(candidate) || length(candidate) == 0) { next } value <- trimws(as.character(candidate[[1]])) if (!is.na(value) && nzchar(value)) { return(value) } } NA_character_ } extract_game_location <- function(competition, event) { venue <- competition$venue %||% event$venue %||% list() venue_name <- trimws(as.character(venue$fullName %||% venue$name %||% venue$shortName %||% NA_character_)) city <- trimws(as.character(venue$address$city %||% NA_character_)) state <- trimws(as.character(venue$address$state %||% venue$address$stateAbbrev %||% NA_character_)) city_state_parts <- c(city, state) city_state_parts <- city_state_parts[!is.na(city_state_parts) & nzchar(city_state_parts)] city_state <- if (length(city_state_parts) > 0) { paste(city_state_parts, collapse = ", ") } else { NA_character_ } if (!is.na(venue_name) && nzchar(venue_name) && !is.na(city_state) && nzchar(city_state)) { return(paste(venue_name, city_state, sep = ", ")) } if (!is.na(venue_name) && nzchar(venue_name)) { return(venue_name) } if (!is.na(city_state) && nzchar(city_state)) { return(city_state) } NA_character_ } extract_game_start_time <- function(status_short_detail, status_detail, competition, event) { time_pattern <- "([0-9]{1,2}:[0-9]{2}\\s*[APap][Mm](?:\\s*[A-Za-z]{1,4})?)" candidates <- c( as.character(status_short_detail %||% ""), as.character(status_detail %||% "") ) for (candidate in candidates) { text <- trimws(candidate) if (is.na(text) || !nzchar(text)) { next } m <- regexpr(time_pattern, text, perl = TRUE) if (m[1] != -1) { out <- regmatches(text, m)[1] out <- toupper(gsub("\\s+", " ", trimws(out))) return(out) } } start_raw <- as.character(competition$date %||% event$date %||% NA_character_) if (!is.na(start_raw) && nzchar(start_raw)) { parsed <- suppressWarnings(as.POSIXct(start_raw, tz = "America/New_York")) if (!is.na(parsed)) { out <- format(parsed, "%I:%M %p") out <- sub("^0", "", out) return(paste(out, "ET")) } } NA_character_ } parse_start_datetime_numeric <- function(value) { text <- trimws(as.character(value %||% "")) if (!nzchar(text)) { return(NA_real_) } fmts <- c( "%Y-%m-%dT%H:%M:%OSZ", "%Y-%m-%dT%H:%MZ", "%Y-%m-%dT%H:%M:%OS%z", "%Y-%m-%dT%H:%M%z" ) for (fmt in fmts) { parsed <- suppressWarnings(as.POSIXct(strptime(text, fmt, tz = "UTC"), tz = "UTC")) if (!is.na(parsed)) { return(as.numeric(parsed)) } } parsed_fallback <- suppressWarnings(as.POSIXct(text, tz = "UTC")) if (!is.na(parsed_fallback)) { return(as.numeric(parsed_fallback)) } NA_real_ } parse_time_label_minutes <- function(value) { text <- toupper(trimws(as.character(value %||% ""))) if (!nzchar(text)) { return(NA_real_) } m <- regexec("([0-9]{1,2}):([0-9]{2})\\s*([AP]M)", text, perl = TRUE) parts <- regmatches(text, m)[[1]] if (length(parts) != 4) { return(NA_real_) } hour <- suppressWarnings(as.integer(parts[2])) minute <- suppressWarnings(as.integer(parts[3])) ampm <- parts[4] if (is.na(hour) || is.na(minute)) { return(NA_real_) } hour <- hour %% 12L if (identical(ampm, "PM")) { hour <- hour + 12L } as.numeric(hour * 60L + minute) } extract_scoreboard_team_name <- function(competitor, fallback = "Team") { team <- competitor$team %||% list() candidates <- c( as.character(team$shortDisplayName %||% NA_character_), as.character(team$location %||% NA_character_), as.character(team$name %||% NA_character_), as.character(team$displayName %||% NA_character_) ) candidates <- trimws(candidates) candidates <- candidates[!is.na(candidates) & nzchar(candidates)] if (length(candidates) == 0) { return(as.character(fallback)) } candidates[[1]] } sanitize_scoreboard_record <- function(value) { text <- trimws(as.character(value %||% "")) if (!nzchar(text)) { return(NA_character_) } m <- regexpr("[0-9]+-[0-9]+(?:-[0-9]+)?", text, perl = TRUE) if (!is.na(m[[1]]) && m[[1]] != -1) { return(regmatches(text, m)[[1]]) } text } extract_scoreboard_record <- function(competitor) { records <- competitor$records %||% list() if (length(records) == 0) { return(NA_character_) } record_value <- function(record_entry) { summary <- as.character( record_entry$summary %||% record_entry$displayValue %||% record_entry$value %||% NA_character_ ) summary <- sanitize_scoreboard_record(summary) if (!is.na(summary) && nzchar(summary)) { return(summary) } wins <- suppressWarnings(as.integer(record_entry$wins %||% NA_integer_)) losses <- suppressWarnings(as.integer(record_entry$losses %||% NA_integer_)) ties <- suppressWarnings(as.integer(record_entry$ties %||% NA_integer_)) if (!is.na(wins) && !is.na(losses)) { if (!is.na(ties) && ties > 0) { return(sprintf("%d-%d-%d", wins, losses, ties)) } return(sprintf("%d-%d", wins, losses)) } NA_character_ } overall_idx <- which(vapply(records, function(rec) { rec_name <- tolower(trimws(as.character(rec$name %||% rec$type %||% rec$description %||% ""))) grepl("overall|total|season", rec_name) }, logical(1))) if (length(overall_idx) > 0) { for (idx in overall_idx) { value <- record_value(records[[idx]]) if (!is.na(value) && nzchar(value)) { return(value) } } } for (rec in records) { value <- record_value(rec) if (!is.na(value) && nzchar(value)) { return(value) } } NA_character_ } parse_espn_score_event <- function(event) { competition <- event$competitions[[1]] %||% list() competitors <- competition$competitors %||% list() away <- purrr::detect(competitors, ~ identical(.x$homeAway, "away")) %||% list() home <- purrr::detect(competitors, ~ identical(.x$homeAway, "home")) %||% list() status <- event$status$type %||% competition$status$type %||% list() away_score <- suppressWarnings(as.integer(away$score %||% NA_character_)) home_score <- suppressWarnings(as.integer(home$score %||% NA_character_)) inning <- suppressWarnings(as.integer(event$status$period %||% competition$status$period %||% NA_integer_)) away_conf_id <- as.character(away$team$conferenceId %||% away$team$conference$id %||% away$conference$id %||% NA_character_) home_conf_id <- as.character(home$team$conferenceId %||% home$team$conference$id %||% home$conference$id %||% NA_character_) away_rank <- extract_competitor_rank(away) home_rank <- extract_competitor_rank(home) away_conference <- extract_conference_name(away) home_conference <- extract_conference_name(home) if (is.na(away_conference) || !nzchar(away_conference)) { away_conference <- if (!is.na(away_conf_id) && nzchar(away_conf_id)) paste0("Conference ", away_conf_id) else NA_character_ } if (is.na(home_conference) || !nzchar(home_conference)) { home_conference <- if (!is.na(home_conf_id) && nzchar(home_conf_id)) paste0("Conference ", home_conf_id) else NA_character_ } away_logo <- extract_logo_url(away) home_logo <- extract_logo_url(home) location <- extract_game_location(competition, event) top25_game <- (!is.na(away_rank) && away_rank <= 25L) || (!is.na(home_rank) && home_rank <= 25L) away_school <- extract_scoreboard_team_name(away, fallback = away$team$displayName %||% "Away") home_school <- extract_scoreboard_team_name(home, fallback = home$team$displayName %||% "Home") away_record <- extract_scoreboard_record(away) home_record <- extract_scoreboard_record(home) away_name <- away$team$displayName %||% away$team$shortDisplayName %||% "Away" home_name <- home$team$displayName %||% home$team$shortDisplayName %||% "Home" status_desc <- as.character(status$description %||% "Scheduled") status_detail <- as.character(status$detail %||% status_desc) status_short_detail <- as.character(status$shortDetail %||% competition$status$type$shortDetail %||% status_detail) start_datetime_utc <- as.character(competition$date %||% event$date %||% NA_character_) start_time <- extract_game_start_time(status_short_detail, status_detail, competition, event) start_sort_key <- parse_start_datetime_numeric(start_datetime_utc) start_time_minutes <- parse_time_label_minutes(start_time) status_state <- tolower(as.character(status$state %||% "")) status_name <- toupper(as.character(status$name %||% "")) status_blob <- toupper(paste(status_name, status_desc, status_detail, status_short_detail)) canceled <- grepl("CANCEL", status_blob) live <- identical(status_state, "in") || grepl("\\bLIVE\\b", status_blob) final <- isTRUE(status$completed) || identical(status_state, "post") conference_flag <- extract_bool(competition$conferenceCompetition %||% event$conferenceCompetition) conference_by_team <- !is.na(away_conf_id) && !is.na(home_conf_id) && nzchar(away_conf_id) && nzchar(home_conf_id) && identical(away_conf_id, home_conf_id) conference_game <- if (!is.na(conference_flag)) conference_flag else conference_by_team status_category <- dplyr::case_when( canceled ~ "canceled", live ~ "live", final ~ "final", TRUE ~ "upcoming" ) status_label <- dplyr::case_when( status_category == "canceled" ~ "Canceled", status_category == "live" ~ "Live", status_category == "final" ~ "Final", TRUE ~ "Upcoming" ) winner <- dplyr::case_when( status_category == "final" & !is.na(away_score) & !is.na(home_score) & away_score > home_score ~ "away", status_category == "final" & !is.na(away_score) & !is.na(home_score) & home_score > away_score ~ "home", TRUE ~ NA_character_ ) status_display <- dplyr::case_when( status_category == "live" ~ paste0("LIVE - ", status_detail), status_category == "final" ~ "Final", status_category == "canceled" ~ "Canceled", TRUE ~ status_detail ) inning_arrow <- dplyr::case_when( status_category != "live" ~ "", grepl("\\bTOP\\b|\\bTOP OF\\b", toupper(status_short_detail)) ~ "up", grepl("\\bBOT\\b|\\bBOTTOM\\b", toupper(status_short_detail)) ~ "down", TRUE ~ "" ) inning_display <- dplyr::case_when( status_category == "final" & is.na(inning) ~ "F", status_category == "final" & inning == 9L ~ "F", status_category == "final" ~ paste0("F/", inning), status_category == "live" & !is.na(inning) ~ as.character(inning), TRUE ~ "-" ) live_late <- status_category == "live" && !is.na(inning) && inning >= 7 live_close <- status_category == "live" && !is.na(away_score) && !is.na(home_score) && abs(away_score - home_score) <= 2 tibble::tibble( game_id = as.character(event$id %||% NA_character_), away = as.character(away_name), home = as.character(home_name), away_school = as.character(away_school), home_school = as.character(home_school), away_record = as.character(away_record), home_record = as.character(home_record), away_rank = away_rank, home_rank = home_rank, away_conference = away_conference, home_conference = home_conference, away_conference_id = away_conf_id, home_conference_id = home_conf_id, away_logo = away_logo, home_logo = home_logo, location = as.character(location), top25_game = top25_game, conference_game = conference_game, inning = inning, away_score = away_score, home_score = home_score, away_score_display = ifelse(is.na(away_score), "-", as.character(away_score)), home_score_display = ifelse(is.na(home_score), "-", as.character(home_score)), status = as.character(status_desc), detail = as.character(status_detail), start_datetime_utc = as.character(start_datetime_utc), start_time = as.character(start_time), start_sort_key = as.numeric(start_sort_key), start_time_minutes = as.numeric(start_time_minutes), status_label = as.character(status_label), status_category = as.character(status_category), status_display = as.character(status_display), inning_display = as.character(inning_display), inning_arrow = as.character(inning_arrow), live = status_category == "live", final = status_category == "final", live_late = live_late, live_close = live_close, winner = winner ) }
sort_scoreboard_games <- function(games) {
if (nrow(games) == 0) {
return(games)
}
if (!("start_datetime_utc" %in% names(games))) {
games$start_datetime_utc <- NA_character_
}
if (!("start_time" %in% names(games))) {
games$start_time <- NA_character_
}
if (!("start_sort_key" %in% names(games))) {
games$start_sort_key <- NA_real_
}
if (!("start_time_minutes" %in% names(games))) {
games$start_time_minutes <- NA_real_
}
games %>%
dplyr::mutate(
sort_bucket = dplyr::case_when(
status_category == "live" ~ 0L,
status_category == "upcoming" ~ 1L,
status_category == "final" ~ 2L,
status_category == "canceled" ~ 3L,
TRUE ~ 4L
),
start_sort_abs = dplyr::coalesce(
as.numeric(start_sort_key),
vapply(start_datetime_utc, parse_start_datetime_numeric, numeric(1))
),
start_sort_time = dplyr::coalesce(
as.numeric(start_time_minutes),
vapply(start_time, parse_time_label_minutes, numeric(1))
),
start_sort_missing_abs = is.na(start_sort_abs),
start_sort = dplyr::if_else(start_sort_missing_abs, start_sort_time, start_sort_abs),
start_sort = ifelse(is.na(start_sort), Inf, start_sort)
) %>%
dplyr::arrange(sort_bucket, start_sort_missing_abs, start_sort, away, home) %>%
dplyr::select(-sort_bucket, -start_sort_abs, -start_sort_time, -start_sort_missing_abs, -start_sort)
}
cache_live_result <- function(key, expr, ttl_seconds) {
cache_get_or_set(
key = key,
expr = expr,
namespace = "live_data",
ttl = ttl_seconds
)
}
fetch_espn_scoreboard <- function(game_date = Sys.Date(), limit = 200) {
date_value <- as.Date(game_date)
if (is.na(date_value)) {
empty <- tibble::tibble()
attr(empty, "fetch_error") <- "Invalid date."
return(empty)
}
cache_key <- make_cache_key("espn_scoreboard", format(date_value, "%Y%m%d"), as.integer(limit))
ttl_seconds <- get_cache_ttl_seconds("WVU_APP_CACHE_TTL_SCORES", default_seconds = 120)
cache_live_result(cache_key, {
date_key <- format(date_value, "%Y%m%d")
url <- sprintf(
"https://site.api.espn.com/apis/site/v2/sports/baseball/college-baseball/scoreboard?dates=%s&limit=%d",
date_key,
as.integer(limit)
)
payload <- tryCatch(
jsonlite::fromJSON(url, simplifyVector = FALSE),
error = function(e) {
NULL
}
)
if (is.null(payload)) {
empty <- tibble::tibble()
attr(empty, "fetch_error") <- "Could not reach ESPN scoreboard API."
return(empty)
}
events <- payload$events %||% list()
if (length(events) == 0) {
return(tibble::tibble())
}
games <- purrr::map_dfr(events, parse_espn_score_event)
if (nrow(games) == 0) {
return(tibble::tibble())
}
sort_scoreboard_games(games)
}, ttl_seconds = ttl_seconds)
}
is_standings_entry <- function(entry) {
is.list(entry) && (!is.null(entry$team) || !is.null(entry$stats) || !is.null(entry$records))