"""
CollegeFootballData API Client
This module handles interactions with the CollegeFootballData API
to retrieve player and team statistics.
"""
import requests
import logging
from typing import Optional, Dict, Any, List
from datetime import datetime
logger = logging.getLogger(__name__)
CFBD_API_BASE_URL = "https://api.collegefootballdata.com"
def search_players(
api_key: str,
search_term: str,
team: Optional[str] = None
) -> Optional[List[Dict[str, Any]]]:
"""
Search for players by name.
Args:
api_key: CFBD API key
search_term: Player name to search for
team: Optional team name to narrow search
Returns:
List of matching players or None if request fails
"""
if not api_key:
logger.error("CFB_API_KEY is not set")
return None
url = f"{CFBD_API_BASE_URL}/player/search"
headers = {
"Authorization": f"Bearer {api_key}",
"Accept": "application/json"
}
params = {
"searchTerm": search_term
}
if team:
params["team"] = team
try:
response = requests.get(url, headers=headers, params=params, timeout=10)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
logger.error(f"Error searching players: {e}")
return None
def get_player_game_stats(
api_key: str,
player_id: int,
year: Optional[int] = None,
season_type: str = "regular"
) -> Optional[List[Dict[str, Any]]]:
"""
Get player statistics for games in a season.
Args:
api_key: CFBD API key
player_id: Player ID from CFBD
year: Season year (defaults to current year if not provided)
season_type: Type of season (regular, postseason, both)
Returns:
List of game statistics or None if request fails
"""
if not api_key:
logger.error("CFB_API_KEY is not set")
return None
if not year:
year = datetime.now().year
url = f"{CFBD_API_BASE_URL}/stats/player/season"
headers = {
"Authorization": f"Bearer {api_key}",
"Accept": "application/json"
}
params = {
"playerId": player_id,
"year": year,
"seasonType": season_type
}
try:
response = requests.get(url, headers=headers, params=params, timeout=10)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
logger.error(f"Error fetching player game stats: {e}")
return None
def get_player_game_logs(
api_key: str,
player_id: int,
year: Optional[int] = None
) -> Optional[List[Dict[str, Any]]]:
"""
Get detailed game-by-game statistics for a player.
This endpoint provides game logs with detailed stats per game.
Args:
api_key: CFBD API key
player_id: Player ID from CFBD
year: Season year (defaults to current year if not provided)
Returns:
List of game logs or None if request fails
"""
if not api_key:
logger.error("CFB_API_KEY is not set")
return None
if not year:
year = datetime.now().year
# Try the game stats endpoint which may have game-by-game data
# If that doesn't work, we'll use the season stats and match with games
url = f"{CFBD_API_BASE_URL}/stats/player/game"
headers = {
"Authorization": f"Bearer {api_key}",
"Accept": "application/json"
}
params = {
"playerId": player_id,
"year": year
}
try:
response = requests.get(url, headers=headers, params=params, timeout=10)
if response.status_code == 200:
return response.json()
else:
# Fallback: try to get season stats and games separately
logger.warning(f"Game stats endpoint returned {response.status_code}, trying alternative approach")
return None
except requests.exceptions.RequestException as e:
logger.error(f"Error fetching player game logs: {e}")
return None
def get_team_games(
api_key: str,
team: str,
year: Optional[int] = None,
season_type: str = "regular"
) -> Optional[List[Dict[str, Any]]]:
"""
Get games for a team in a season.
Args:
api_key: CFBD API key
team: Team name
year: Season year (defaults to current year if not provided)
season_type: Type of season (regular, postseason, both)
Returns:
List of games or None if request fails
"""
if not api_key:
logger.error("CFB_API_KEY is not set")
return None
if not year:
year = datetime.now().year
url = f"{CFBD_API_BASE_URL}/games"
headers = {
"Authorization": f"Bearer {api_key}",
"Accept": "application/json"
}
params = {
"team": team,
"year": year,
"seasonType": season_type
}
try:
response = requests.get(url, headers=headers, params=params, timeout=10)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
logger.error(f"Error fetching team games: {e}")
return None
def get_team_records(
api_key: str,
team: str,
year: Optional[int] = None
) -> Optional[Dict[str, Any]]:
"""
Get team season records.
Args:
api_key: CFBD API key
team: Team name
year: Season year (defaults to current year if not provided)
Returns:
Team record data or None if request fails
"""
if not api_key:
logger.error("CFB_API_KEY is not set")
return None
if not year:
year = datetime.now().year
url = f"{CFBD_API_BASE_URL}/records"
headers = {
"Authorization": f"Bearer {api_key}",
"Accept": "application/json"
}
params = {
"team": team,
"year": year
}
try:
response = requests.get(url, headers=headers, params=params, timeout=10)
response.raise_for_status()
records = response.json()
# Return first record if list, or the record itself
if isinstance(records, list) and len(records) > 0:
return records[0]
return records
except requests.exceptions.RequestException as e:
logger.error(f"Error fetching team records: {e}")
return None
def get_team_rankings(
api_key: str,
team: Optional[str] = None,
year: Optional[int] = None,
week: Optional[int] = None,
season_type: str = "regular",
poll: str = "ap"
) -> Optional[List[Dict[str, Any]]]:
"""
Get team rankings from polls.
Args:
api_key: CFBD API key
team: Optional team name to filter
year: Season year (defaults to current year if not provided)
week: Week number (defaults to latest if not provided)
season_type: Type of season (regular, postseason)
poll: Poll type (ap, coaches, cfp)
Returns:
List of rankings or None if request fails
"""
if not api_key:
logger.error("CFB_API_KEY is not set")
return None
if not year:
year = datetime.now().year
url = f"{CFBD_API_BASE_URL}/rankings"
headers = {
"Authorization": f"Bearer {api_key}",
"Accept": "application/json"
}
params = {
"year": year,
"seasonType": season_type,
"poll": poll
}
if week:
params["week"] = week
try:
response = requests.get(url, headers=headers, params=params, timeout=10)
response.raise_for_status()
rankings = response.json()
# Filter by team if provided
if team and isinstance(rankings, list):
team_lower = team.lower()
for ranking_entry in rankings:
polls = ranking_entry.get("polls", [])
for poll_data in polls:
rankings_list = poll_data.get("ranks", [])
for rank_entry in rankings_list:
if rank_entry.get("school", "").lower() == team_lower:
return rank_entry
return None
return rankings
except requests.exceptions.RequestException as e:
logger.error(f"Error fetching team rankings: {e}")
return None
def format_team_recent_results(
team_name: str,
games: List[Dict[str, Any]]
) -> Dict[str, Any]:
"""
Format team recent game results.
Args:
team_name: Name of the team
games: List of game data (should be sorted by date, most recent first)
Returns:
Formatted response with last 5 games including scores and outcomes
"""
from src.team_normalizer import normalize_team_name
# Sort games by date (most recent first)
# CFBD API typically returns games in chronological order, so reverse for most recent
# Handle both camelCase and snake_case for date fields
sorted_games = sorted(
games,
key=lambda x: x.get("startDate") or x.get("start_date", "") or x.get("week", 0),
reverse=True
)
# Take the last 5 games
recent_games = sorted_games[:5] if len(sorted_games) > 5 else sorted_games
# Normalize team name for matching
team_normalized = normalize_team_name(team_name)
team_lower = team_name.lower()
formatted_games = []
for game in recent_games:
# CFBD API uses camelCase, try both formats
home_team = game.get("homeTeam") or game.get("home_team", "")
away_team = game.get("awayTeam") or game.get("away_team", "")
home_points = game.get("homePoints") or game.get("home_points")
away_points = game.get("awayPoints") or game.get("away_points")
# Determine if the requested team is home - use flexible matching
home_normalized = normalize_team_name(home_team)
away_normalized = normalize_team_name(away_team)
home_lower = home_team.lower()
away_lower = away_team.lower()
is_home = (
team_normalized == home_normalized or
team_normalized in home_normalized or
home_normalized in team_normalized or
team_lower == home_lower or
team_lower in home_lower or
home_lower in team_lower
)
team_score = home_points if is_home else away_points
opponent_score = away_points if is_home else home_points
opponent = away_team if is_home else home_team
# Determine result
if team_score is not None and opponent_score is not None:
if team_score > opponent_score:
result = "Win"
elif team_score < opponent_score:
result = "Loss"
else:
result = "Tie"
else:
result = "Scheduled" # Game hasn't been played yet
# Handle both camelCase and snake_case for date
start_date = game.get("startDate") or game.get("start_date") or game.get("week", "Unknown")
game_data = {
"date": start_date,
"opponent": opponent,
"location": "Home" if is_home else "Away",
"team_score": team_score,
"opponent_score": opponent_score,
"result": result,
"game_id": game.get("id")
}
formatted_games.append(game_data)
# Calculate win/loss record from recent games
wins = sum(1 for g in formatted_games if g["result"] == "Win")
losses = sum(1 for g in formatted_games if g["result"] == "Loss")
ties = sum(1 for g in formatted_games if g["result"] == "Tie")
return {
"team": team_name,
"recent_results": formatted_games,
"recent_record": f"{wins}-{losses}" + (f"-{ties}" if ties > 0 else ""),
"total_games": len(games)
}
def format_team_info(
team_name: str,
record: Optional[Dict[str, Any]],
rankings: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Format team season information.
Args:
team_name: Name of the team
record: Team record data from CFBD API
rankings: Optional rankings data
Returns:
Formatted response with team season overview
"""
result = {
"team": team_name,
"year": datetime.now().year
}
# Extract record information
if record:
overall = record.get("total", {})
conference = record.get("conferenceGames", {})
result["overall_record"] = f"{overall.get('wins', 0)}-{overall.get('losses', 0)}"
if overall.get("ties", 0) > 0:
result["overall_record"] += f"-{overall.get('ties', 0)}"
if conference:
result["conference_record"] = f"{conference.get('wins', 0)}-{conference.get('losses', 0)}"
if conference.get("ties", 0) > 0:
result["conference_record"] += f"-{conference.get('ties', 0)}"
result["conference"] = record.get("conference", "Unknown")
else:
result["overall_record"] = "Unknown"
result["conference_record"] = None
result["conference"] = None
# Extract rankings
result["rankings"] = {}
if rankings:
rank = rankings.get("rank")
if rank:
result["rankings"]["ap_poll"] = rank
else:
result["rankings"]["ap_poll"] = None
# Note: Could fetch coaches and CFP polls separately if needed
result["rankings"]["coaches_poll"] = None
result["rankings"]["cfp_rank"] = None
return result
def format_player_recent_stats(
player_name: str,
team: Optional[str],
games: List[Dict[str, Any]]
) -> Dict[str, Any]:
"""
Format player statistics from recent games.
Args:
player_name: Name of the player
team: Team name (if available)
games: List of game statistics (should be sorted by date, most recent first)
Returns:
Formatted response with last 5 games
"""
# Take the last 5 games (assuming they're sorted by date)
recent_games = games[:5] if len(games) > 5 else games
formatted_games = []
for game in recent_games:
game_data = {
"date": game.get("week", "Unknown"),
"opponent": game.get("opponent", "Unknown"),
"game_id": game.get("gameId"),
}
# Extract relevant stats based on position
# CFBD API returns different stats for different positions
stats = {}
# Passing stats
if "passing" in game or "passYards" in game:
stats["passing_yards"] = game.get("passYards") or game.get("passing", {}).get("yards", 0)
stats["pass_touchdowns"] = game.get("passTDs") or game.get("passing", {}).get("touchdowns", 0)
stats["interceptions"] = game.get("interceptions") or game.get("passing", {}).get("interceptions", 0)
stats["completions"] = game.get("completions") or game.get("passing", {}).get("completions", 0)
stats["attempts"] = game.get("attempts") or game.get("passing", {}).get("attempts", 0)
# Rushing stats
if "rushing" in game or "rushYards" in game:
stats["rushing_yards"] = game.get("rushYards") or game.get("rushing", {}).get("yards", 0)
stats["rush_touchdowns"] = game.get("rushTDs") or game.get("rushing", {}).get("touchdowns", 0)
stats["carries"] = game.get("carries") or game.get("rushing", {}).get("carries", 0)
# Receiving stats
if "receiving" in game or "recYards" in game:
stats["receiving_yards"] = game.get("recYards") or game.get("receiving", {}).get("yards", 0)
stats["receptions"] = game.get("receptions") or game.get("receiving", {}).get("receptions", 0)
stats["rec_touchdowns"] = game.get("recTDs") or game.get("receiving", {}).get("touchdowns", 0)
# General stats
stats["total_touchdowns"] = game.get("totalTDs", 0)
stats["fumbles"] = game.get("fumbles", 0)
game_data["stats"] = stats
formatted_games.append(game_data)
return {
"player": player_name,
"team": team or "Unknown",
"recent_games": formatted_games,
"total_games": len(games)
}