New features
statcast_search()no longer renames Savant CSV columns by position.
Savant's own header row is trusted as-is (its names are already the
canonical identifiers), so a column inserted mid-export can never again
silently shift every downstream value — the "n_thruorder_pitchershowing
AGE" failure class (#337, #354, #371, #390, #408, #416). Genuinely new
Savant columns now arrive under their own names with an informational
message.statcast_search_minors()andstatcast_search_wbc()— the minor-league
and World Baseball Classic Statcast searches (separate Savant routes, same
columns) (#339, #395).statcast_pitch_colors()— Baseball Savant's pitch-type color palette as
a tibble for consistent pitch charts (#356).- Eleven
load_mlb_*()loaders for the pre-computed MLB model datasets on
the sportsdataverse-data releases (expected stats/HR, batter projections,
Stuff+/Command+/xERA, OAA, catcher framing, RE24 matrix, WE table, WPA),
mirroring sportsdataverse-py's loader surface. mlb_stats()andmlb_teams_stats()gainsit_codesfor situational
splits viastat_type = "statSplits"(vs L/R, home/away, RISP, ...)
(#278, #304, #383). Note the upstream leaders endpoints ignoresitCodes.fg_projections()— FanGraphs player projections (Steamer, ZiPS, ATC,
THE BAT and rest-of-season variants; batting and pitching) (#182).ggpitchzone()— pitch-location plot from the catcher's perspective with
the strike zone overlaid, colored with the Savant pitch-type palette
(#260).
Bug fixes
-
mlb_pbp()no longer back-fills the at-bat-levelmatchup.postOn*/
matchup.splits.menOnBasecolumns across at-bat and half-inning
boundaries — bases-empty plays previously inherited baserunner ids from
the next at-bat (#263). This corrects long-standing output. -
mlb_pbp()'scount.balls.start/count.strikes.startare now true
pre-pitch counts (the previous event's post-pitch count within the
at-bat, 0-0 before the first pitch). They previously carried the
post-pitch counts under a pre-pitch name (#131, #252). -
mlb_game_info()degrades gracefully for games that have not started:
venue/weather/status return withNAattendance and first-pitch fields
instead of erroring (#363). -
mlb_pbp()works for pre-2010 games again: play events are tied to their
at-bat by position instead of anendTimejoin the older feeds don't
carry (#233). Modern-game output is unchanged. -
mlb_pbp(add_base_state = TRUE)appends per-event pre-pitch base-occupancy
columns (pre_on_1b/pre_on_2b/pre_on_3b, runner MLBAM ids)
reconstructed from the feed's runner-movement records, including the
extra-innings automatic runner. Validated pitch-for-pitch against
Statcast's ownon_1b/2b/3bcolumns: 4,388/4,388 states agree across a
full 15-game slate (#276). -
bref_standings_on_date()supports every divisional era: pre-1969
league-only standings, 1969-1993 East/West, and 1994+ East/Central/West.
Tables are named from the page's own section headings, and requesting a
division that did not exist for the date errors with the era's available
options (#253). -
mlb_rosters()documents themlb_teams()team-id lookup (#376). -
Added read-only Fox Sports "Bifrost" MLB wrappers (
fox_mlb_*()) overapi.foxsports.com/bifrost/v1/mlb/*:fox_mlb_team_roster(),fox_mlb_team_stats(),fox_mlb_team_gamelog(),fox_mlb_standings(),fox_mlb_league_leaders(), andfox_mlb_odds(). They flatten Fox's layout-oriented JSON (sections → tables → rows → cells) into tidybaseballr_datatibbles. Fox does not expose MLB play-by-play or boxscore viaevent/{id}/data, so those are intentionally omitted. Parallels the cfbfastR / hoopR / fastRhockey / sportsdataverse-pyfox_*families; reverse-engineering notes + an OpenAPI 3.1 spec live in thesdv-internal-refsrepo. -
Added a full ESPN MLB wrapper family (
espn_mlb_*(), 100+ functions) mirroring the naming and structure of the sister SportsDataverse packages (hoopRespn_nba_*/espn_mbb_*, wehoopespn_wnba_*/espn_wbb_*, cfbfastRespn_cfb_*). It covers ESPN's three public hosts:- Game data (
site.api.espn.com):espn_mlb_scoreboard(),espn_mlb_pbp()(pitch/at-bat level),espn_mlb_team_box(),espn_mlb_player_box(),espn_mlb_game_all(),espn_mlb_game_rosters(),espn_mlb_teams(),espn_mlb_team_current_roster(),espn_mlb_standings(),espn_mlb_betting(). Baseball-specific extractors with no basketball analogue:espn_mlb_game_probables()(probable / announced starting pitchers) andespn_mlb_game_info()(venue, attendance, game duration, and the umpire crew). - Reference data (
sports.core.api.espn.com): athletes, coaches, seasons, franchises, draft, futures, leaders, positions, powerindex, events, plays, per-competitor game detail, and theseason_*family. - Athlete data (
site.web.api.espn.com):espn_mlb_player_overview(),espn_mlb_player_gamelog(),espn_mlb_player_splits(),espn_mlb_player_stats(),espn_mlb_team_stats().
Box scores are returned as wide, clean-namedbaseballr_datatibbles (one row per team forespn_mlb_team_box(); one row per athlete-side, taggedstat_group, forespn_mlb_player_box()), with the baseballbatting/pitching/fieldingstat groups parsed natively. ESPN requests share an internalhttr2layer (.retry_request()) that honoursoptions(baseballr.proxy = ...). Live tests are gated behindESPN_MLB_TESTS=1(skip_espn_test()).
- Game data (
-
Added a full ESPN College Baseball wrapper family (
espn_college_baseball_*(), 70 functions) covering ESPN's NCAA college-baseball endpoints (sportbaseball, leaguecollege-baseball). They are thin twins of theespn_mlb_*()family over the same league-parameterized helpers, so return shapes match their MLB counterparts. Coverage includes game data (espn_college_baseball_scoreboard(),espn_college_baseball_pbp(),espn_college_baseball_team_box(),espn_college_baseball_player_box(),espn_college_baseball_game_all(),espn_college_baseball_game_rosters()), teams / standings / rankings / news / conferences, the team-detail and athlete families, the core-v2 reference graph (seasons, season types, groups, venues, coaches, calendar), per-competitor game sub-resources, and tournaments (the College World Series is an ESPN tournament). Endpoints ESPN does not serve for college baseball (injuries, win-probability, betting futures/odds, athlete contracts, transactions, draft, depth charts) are intentionally omitted. A new helpermost_recent_college_baseball_season()supplies the season default. Endpoint/return catalog and captured sample bodies live in thesdv-internal-refsrepo.
Bug fixes
bref_standings_on_date(),bref_daily_batter(),bref_daily_pitcher(), andbref_team_results()now retry on HTTP 429 (Too Many Requests) from Baseball Reference with exponential backoff (up to 3 tries) via a newbref_read_html()helper that wrapshttr2::req_retry(). Previously, a 429 was swallowed by thetryCatchinto a misleading "Invalid arguments or no data available" message, leaving the returned data asNULL(#344). Error handlers also now print the original error message for diagnosis.- The NCAA family (
ncaa_schedule_info(),ncaa_roster(),ncaa_teams(),ncaa_team_player_stats(),ncaa_pbp(),ncaa_game_logs(),ncaa_lineups(),ncaa_park_factor()) can fetchstats.ncaa.orgagain — clearing the new Akamai 403 / softbm-verifyblock — after the site simultaneously (a) tightened its Akamai bot protection and (b) redesigned its pages (#410). Each affected scraper was also updated for the new page layout (details below); clearing the block needs the optionalchromote+ Google Chrome browser fallback.- Akamai bypass.
stats.ncaa.orgnow blocks everyhttr2/curlrequest — a hard 403 or a soft HTTP-200bm-verifyinterstitial — on the client's TLS/sensor fingerprint, so no header set can clear it, and a vanilla headless Chrome is blocked too (Akamai fingerprints theHeadlessChromeuser-agent and thenavigator.webdriverflag).request_with_proxy()now falls back to a stealth headless-Chrome fetch (real Chrome user-agent,navigator.webdriverhidden) via the optionalchromotepackage, wrapping the rendered HTML in a synthetic response so every caller is unaffected. The browser session is cached and reused.chromote+ Google Chrome are an optional (Suggests) dependency; when absent the scrapers emit a clear install message. The fasthttr2path is used whenever Akamai allows it and resumes automatically if the edge relaxes. - Page-redesign parsers. Each scraper was updated for the new layout:
ncaa_schedule_info()selects the schedule table by its header columns (it moved out of<fieldset>into a plain<table>);ncaa_team_player_stats()reads the migrated/teams/{season_team_id}/season_to_date_stats#stat_grid;ncaa_pbp()follows the "Play By Play" tab and parses thetable.tableinning tables;ncaa_game_logs()reads the/players/{id}#game_log/#career_totalsgrids; andncaa_lineups()derives the batting order from theindividual_statsbox scores.
- Akamai bypass.
ncaa_game_logs()andncaa_team_player_stats()gaintype = "fielding"support (previously only"batting"/"pitching").edge_frequency(df, group = ...)now groups by the column named in thegroupargument. It previously grouped by a literal column namedgroup(.data$group), so passing agroup(e.g."pitcher") errored unless the data happened to have agroupcolumn and otherwise ignored the argument.statcast_search()now assigns Baseball Savant's columns length-tolerantly and recognizes the newest Savant column definitions, so columns added to (or reordered in) the CSV export no longer break the function with a "can't assign N names to an M column data.table" error (#337, #354, #371, #390).statcast_search()now recognizes Baseball Savant'smiss_distancecolumn (the bat-tracking swing-and-miss distance). Savant inserted it mid-frame — betweenswing_lengthandestimated_slg_using_speedangle, not as a trailing column — so the length-tolerant positional rename labeled it (and every column after it) one position off:miss_distancedropped out and the trailing bat-tracking columns were silently mislabeled.miss_distanceis now in the canonical column list at its real position, so it comes through under its own name and the columns after it line up again (#408).statcast_search()(viaprocess_statcast_payload()) now normalizes blank character fields toNA. Baseball Savant exports empty fields as empty strings, so character columns such asevents,des, anddescriptionwere coming back as""rather thanNA(numeric columns were unaffected). This broke downstream helpers that treat missing categorical values asNA-- e.g.metrics_linear_weights_savant()was not folding balls/strikes into theeventscolumn (#275).statcast_search()(viaprocess_statcast_payload()) returns data again. Baseball Savant's CSV export ships thepitcherandfielder_2columns twice (the trailing repeats carry identical values), and after the positional header rename those duplicate names survived into the payload.dplyr::across()/mutate()reject a frame with duplicate names (Can't transform a data frame with duplicate names), so the function now drops the duplicate-named columns (keeping the first occurrence) before any column-wise transform, class-aware for thedata.tablepayload returned bycsv_from_url().sptrc_team_active_payroll()andsptrc_league_payrolls()updated for Spotrac's new/payroll/_/year/<year>/URLs and changed table schema; parsing is now resilient to column-order changes and both functions return data again (#392).- Wrappers that build their result inside
tryCatch()now initialize the return value first, so an API error returns an empty value with aclimessage instead of anobject '<var>' not founderror. mlb_game_timecodes()no longer returnsNULL; it renamed its single column by the literal name".", which newer R versions no longer use for the coerced column, so it now renames by position.mlb_homerun_derby()andmlb_homerun_derby_bracket()return data again; their column selections are now wrapped indplyr::any_of()so the droppednum_batterscolumn (no longer in the MLB Stats API response) no longer errors the parse.- The NCAA functions (
ncaa_*()) work again.stats.ncaa.orgmoved behind Akamai, which returns HTTP 403 "Access Denied" to requests lacking modern browser headers;.ncaa_headers()now sends the current client-hint / fetch-metadata headers (sec-ch-ua,Sec-Fetch-*,Upgrade-Insecure-Requests), andncaa_teams()/ncaa_team_player_stats()now request overhttps.request_with_proxy()now sleeps 5 seconds after every request (the NCAA edge aggressively rate-limits / IP-bans) and accepts aproxyargument (a URL string or a list ofhttr2::req_proxy()args, defaulting togetOption("baseballr.proxy")) to route and rotate requests through a proxy. (Verified:ncaa_roster(),ncaa_teams().) ncaa_teams()no longer returnsNAids.stats.ncaa.orgmigrated team links from the legacy/team/{team_id}/{season_id}pair to a single modern/teams/{season_team_id}resource, so the previousstr_extract("(\\d+)/(\\d+)")matched nothing andteam_id/season_idcame backNA. The function now extracts ids from whichever url shape is present (legacy rows populateteam_id/season_id; modern rows populate the newseason_team_idcolumn) so the id columns are never all-NAwhen the site flips formats.ncaa_team_player_stats()builds its request correctly and fails gracefully (#379). The batting branch previously passedidtwice and never sentyear_stat_category_id, so it could not return batting stats; both branches now key/team/{team_id}/statson the seasonidand the per-type stat-category id (batting_id/pitching_id) from the season-id lookup. The stats endpoint is currently gated behind Akamai'sbm-verifyinterstitial challenge (verified: the URL and ids are correct and work in a real browser, but a static request — even with valid session cookies carried from the roster page — is served the challenge, not the data). When challenged, the function now emits an informativecliwarning and returns an empty tibble (via the new internal.ncaa_is_interstitial()guard) instead of silently scraping a table-less page.fg_batter_leaders()returns data for FanGraphs' handedness-split leaderboards (month = 13for vs LHP,month = 14for vs RHP) instead ofNULL(#323). The split boards return a much narrower column projection (~83-97 columns) than the full board (~475), and the function'srename()/select()referenced columns absent from that projection, so the parse errored and was swallowed toNULL. The leading rename and column selection now usedplyr::any_of(), so a column missing from a narrower split is skipped rather than failing the whole parse. The sameany_of()hardening was applied tofg_pitcher_leaders()andfg_fielder_leaders(), which shared the strictselect()/rename()pattern (verified live:fg_pitcher_leaders(month = "13")returns data).fg_batter_leaders(),fg_pitcher_leaders(),fg_fielder_leaders(),fg_team_batter(),fg_team_pitcher(), andfg_team_fielder()now honorstartdate/enddateout of the box (#326). FanGraphs only applies a custom date range whenmonth = "1000", so supplying dates with the defaultmonth = "0"silently returned the full-season board.monthis now set to"1000"automatically when a date range is requested without an explicitmonth, and themonthdocumentation covers the special values ("13"vs LHP,"14"vs RHP,"1000"date range). Verified live: a 2023-03-26..2023-04-26 pull returns the date-ranged board (max 118 PA) instead of the full season (max 753 PA).ncaa_park_factor()no longer errors mid-pipeline when the NCAA schedule is unavailable (#302).ncaa_schedule_info()can come back empty --stats.ncaa.orgrate-limits / blocks automated requests and its schedule schema has drifted -- which left the schedule frame without thehome_team_id/ score columns the function mutates on, throwing a confusingColumn 'home_team_id' not found(historically'opponent' not found). The function now guards the schedule frame and returns an empty result with an informativecliwarning instead, in line with the rest of the package.fg_guts(),fg_park(), andfg_park_hand()return data again. FanGraphs replaced the legacy ASP.NET grid (HTML idGutsBoard1_dg1_ctl00) with a modern.table-scrolldata grid, so the hard-coded id selector matched nothing; they now target the current grid, usehttps://, and fetch through the okhttp User-Agent (below) so the Cloudflare challenge doesn't block them.- All FanGraphs functions return data again under FanGraphs' current Cloudflare posture. As of 2026-06-03 FanGraphs serves a Cloudflare JS challenge (HTTP 403,
cf-mitigated: challenge) to every unrecognized client — including the plain/libraryUser-Agentthat previously worked — and no header/TLS tweak passes it (it requires a JS runtime). The challenge exempts the okhttp client the FanGraphs mobile app uses, so the FanGraphs requests now sendUser-Agent: okhttp/4.12.0and get HTTP 200 with normal JSON. A dedicated internalfg_api_call()helper (okhttp UA) carries the leaders/team functions (fg_batter_leaders(),fg_pitcher_leaders(),fg_fielder_leaders(),fg_team_batter(),fg_team_pitcher(),fg_team_fielder()); the game-log functions (fg_batter_game_logs(),fg_pitcher_game_logs(),fg_milb_batter_game_logs(),fg_milb_pitcher_game_logs()) and the.aspxscrapers send the same UA. The sharedmlb_api_call()keeps its plain package UA for the MLB Stats API (which ignores it). (#404, #402, #397, #389, #385, #384, #373, #369, #361, #358, #353, #349, #343; okhttp-UA approach from PR #405.)
Documentation
- Added a
descriptioncolumn to the@returntables of all currently-returning functions (matching thewehoop/hoopRdocumentation style), regenerated from live API responses so columns, types, and descriptions reflect the current output.
Internal / infrastructure
- Migrated the entire HTTP layer from
httrtohttr2and droppedhttras a dependency.httr::modify_url()->httr2::url_modify_query(); the MLB/FanGraphs helpers and the NCAA helpers (request_with_proxy()/check_status()) now usehttr2::request()/req_retry()/req_perform()/resp_body_string()/resp_status().request_with_proxy()gained aproxyargument (a URL string or a list of [httr2::req_proxy()] args, defaulting togetOption("baseballr.proxy")) for routing NCAA requests through a proxy. - Migrated the package's internal code from the magrittr pipe (
%>%) to the native pipe (|>);Dependsis nowR (>= 4.1.0). - User-facing messages migrated to the
clipackage. - Column selections that drop known-transient columns now use
dplyr::any_of()for resilience to upstream schema drift. - Added project documentation and community health files (
CLAUDE.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.github/copilot-instructions.md, pull request template), refreshed the_pkgdown.ymlreference index, and updated GitHub Actions workflows to current (Node 20-compatible) action versions.