From 5d053f70946f808762e50f844d09a7a23787e119 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Sun, 10 Apr 2016 12:57:57 -0500 Subject: [PATCH] Add NBA backend for scores module --- i3pystatus/scores/nba.py | 314 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 i3pystatus/scores/nba.py diff --git a/i3pystatus/scores/nba.py b/i3pystatus/scores/nba.py new file mode 100644 index 0000000..4f5e3b6 --- /dev/null +++ b/i3pystatus/scores/nba.py @@ -0,0 +1,314 @@ +from i3pystatus.core.util import internet, require +from i3pystatus.scores import ScoresBackend + +import copy +import pytz +import time +from datetime import datetime + +LIVE_URL = 'http://www.nba.com/gametracker/#/%s/lp' +SCOREBOARD_URL = 'http://www.nba.com/scores' +API_URL = 'http://data.nba.com/data/10s/json/cms/noseason/scoreboard/%04d%02d%02d/games.json' +STANDINGS_URL = 'http://data.nba.com/data/json/cms/%s/league/standings.json' + + +class NBA(ScoresBackend): + ''' + Backend to retrieve NBA scores. For usage examples, see :py:mod:`here + <.scores>`. + + .. rubric:: Available formatters + + * `{home_name}` — Name of home team + * `{home_city}` — Name of home team's city + * `{home_abbrev}` — 3-letter abbreviation for home team's city + * `{home_score}` — Home team's current score + * `{home_wins}` — Home team's number of wins + * `{home_losses}` — Home team's number of losses + * `{home_favorite}` — Displays the value for the :py:mod:`.scores` module's + ``favorite`` attribute, if the home team is one of the teams being + followed. Otherwise, this formatter will be blank. + * `{away_name}` — Name of away team + * `{away_city}` — Name of away team's city + * `{away_abbrev}` — 2 or 3-letter abbreviation for away team's city + * `{away_score}` — Away team's current score + * `{away_wins}` — Away team's number of wins + * `{away_losses}` — Away team's number of losses + * `{away_favorite}` — Displays the value for the :py:mod:`.scores` module's + ``favorite`` attribute, if the away team is one of the teams being + followed. Otherwise, this formatter will be blank. + * `{time_remaining}` — Time remaining in the current quarter/OT period + * `{quarter}` — Number of the current quarter + * `{venue}` — Name of arena where game is being played + * `{start_time}` — Start time of game in system's localtime (supports + strftime formatting, e.g. `{start_time:%I:%M %p}`) + * `{overtime}` — If the game ended in overtime, this formatter will show + ``OT``. If the game ended in regulation, or has not yet completed, this + formatter will be blank. + + .. rubric:: Team abbreviations + + * **ATL** — Atlanta Hawks + * **BKN** — Brooklyn Nets + * **BOS** — Boston Celtics + * **CHA** — Charlotte Hornets + * **CHI** — Chicago Bulls + * **CLE** — Cleveland Cavaliers + * **DAL** — Dallas Mavericks + * **DEN** — Denver Nuggets + * **DET** — Detroit Pistons + * **GSW** — Golden State Warriors + * **HOU** — Houston Rockets + * **IND** — Indiana Pacers + * **MIA** — Miami Heat + * **MEM** — Memphis Grizzlies + * **MIL** — Milwaukee Bucks + * **LAC** — Los Angeles Clippers + * **LAL** — Los Angeles Lakers + * **MIN** — Minnesota Timberwolves + * **NOP** — New Orleans Pelicans + * **NYK** — New York Knicks + * **OKC** — Oklahoma City Thunder + * **ORL** — Orlando Magic + * **PHI** — Philadelphia 76ers + * **PHX** — Phoenix Suns + * **POR** — Portland Trailblazers + * **SAC** — Sacramento Kings + * **SAS** — San Antonio Spurs + * **TOR** — Toronto Raptors + * **UTA** — Utah Jazz + * **WAS** — Washington Wizards + ''' + interval = 300 + + settings = ( + ('favorite_teams', 'List of abbreviations of favorite teams. Games ' + 'for these teams will appear first in the scroll ' + 'list. A detailed description of how games are ' + 'ordered can be found ' + ':ref:`here `.'), + ('all_games', 'If set to ``True``, all games will be present in ' + 'the scroll list. If set to ``False``, then only ' + 'games from **favorite_teams** will be present in ' + 'the scroll list.'), + ('display_order', 'When **all_games** is set to ``True``, this ' + 'option will dictate the order in which games from ' + 'teams not in **favorite_teams** are displayed'), + ('format_no_games', 'Format used when no tracked games are scheduled ' + 'for the current day (does not support formatter ' + 'placeholders)'), + ('format_pregame', 'Format used when the game has not yet started'), + ('format_in_progress', 'Format used when the game is in progress'), + ('format_final', 'Format used when the game is complete'), + ('team_colors', 'Dictionary mapping team abbreviations to hex color ' + 'codes. If overridden, the passed values will be ' + 'merged with the defaults, so it is not necessary to ' + 'define all teams if specifying this value.'), + ('date', 'Date for which to display game scores, in **YYYY-MM-DD** ' + 'format. If unspecified, the current day\'s games will be ' + 'displayed starting at 10am Eastern time, with last ' + 'evening\'s scores being shown before then. This option ' + 'exists primarily for troubleshooting purposes.'), + ('live_url', 'URL string to launch NBA Game Tracker. This value ' + 'should not need to be changed.'), + ('scoreboard_url', 'Link to the NBA.com scoreboard page. Like ' + '**live_url**, this value should not need to be ' + 'changed.'), + ('api_url', 'Alternate URL string from which to retrieve score data. ' + 'Like, **live_url**, this value should not need to be ' + 'changed.'), + ('standings_url', 'Alternate URL string from which to retrieve team ' + 'standings. Like **live_url**, this value should ' + 'not need to be changed.'), + ) + + required = () + + _default_colors = { + 'ATL': '#E2383F', + 'BKN': '#DADADA', + 'BOS': '#178D58', + 'CHA': '#00798D', + 'CHI': '#CD1041', + 'CLE': '#FDBA31', + 'DAL': '#006BB7', + 'DEN': '#5593C3', + 'DET': '#207EC0', + 'GSW': '#DEB934', + 'HOU': '#CD1042', + 'IND': '#FFBB33', + 'MIA': '#A72249', + 'MEM': '#628BBC', + 'MIL': '#4C7B4B', + 'LAC': '#ED174C', + 'LAL': '#FDB827', + 'MIN': '#35749F', + 'NOP': '#A78F59', + 'NYK': '#F68428', + 'OKC': '#F05033', + 'ORL': '#1980CB', + 'PHI': '#006BB7', + 'PHX': '#E76120', + 'POR': '#B03037', + 'SAC': '#7A58A1', + 'SAS': '#DADADA', + 'TOR': '#CD112C', + 'UTA': '#4B7059', + 'WAS': '#E51735', + } + + _valid_teams = [x for x in _default_colors] + _valid_display_order = ['in_progress', 'final', 'pregame'] + + display_order = _valid_display_order + format_no_games = 'NBA: No games' + format_pregame = '[{scroll} ]NBA: [{away_favorite} ]{away_abbrev} ({away_wins}-{away_losses}) at [{home_favorite} ]{home_abbrev} ({home_wins}-{home_losses}) {start_time:%H:%M %Z}' + format_in_progress = '[{scroll} ]NBA: [{away_favorite} ]{away_abbrev} {away_score}[ ({away_power_play})], [{home_favorite} ]{home_abbrev} {home_score}[ ({home_power_play})] ({time_remaining} {quarter})' + format_final = '[{scroll} ]NBA: [{away_favorite} ]{away_abbrev} {away_score} ({away_wins}-{away_losses}) at [{home_favorite} ]{home_abbrev} {home_score} ({home_wins}-{home_losses}) (Final[/{overtime}])' + team_colors = _default_colors + live_url = LIVE_URL + scoreboard_url = SCOREBOARD_URL + api_url = API_URL + standings_url = STANDINGS_URL + + def check_scores(self): + self.get_api_date() + url = self.api_url % (self.date.year, self.date.month, self.date.day) + + response = self.api_request(url) + game_list = self.get_nested(response, + 'sports_content:games:game', + default=[]) + + standings_year = self.get_nested( + response, + 'sports_content:sports_meta:season_meta:standings_season_year', + default=self.date.year, + ) + + stats_list = self.get_nested( + self.api_request(self.standings_url % standings_year), + 'sports_content:standings:team', + default=[], + ) + team_stats = {} + for item in stats_list: + try: + key = item.pop('abbreviation') + except KeyError: + self.logger.debug('Error occurred obtaining team stats', + exc_info=True) + continue + team_stats[key] = item.get('team_stats', {}) + + self.logger.debug('%s team stats: %s', + self.__class__.__name__, team_stats) + + # Convert list of games to dictionary for easy reference later on + data = {} + team_game_map = {} + for game in game_list: + try: + id_ = game['game_url'] + except KeyError: + continue + + try: + for key in ('home', 'visitor'): + team = game[key]['abbreviation'].upper() + if team in self.favorite_teams: + team_game_map.setdefault(team, []).append(id_) + except KeyError: + continue + + data[id_] = game + # Merge in the team stats, because they are not returned in the + # initial API request. + for key in ('home', 'visitor'): + team = data[id_][key]['abbreviation'].upper() + data[id_][key].update(team_stats.get(team, {})) + + self.interpret_api_return(data, team_game_map) + + def process_game(self, game): + ret = {} + + def _update(ret_key, game_key=None, callback=None, default='?'): + ret[ret_key] = self.get_nested(game, + game_key or ret_key, + callback=callback, + default=default) + + self.logger.debug('Processing %s game data: %s', + self.__class__.__name__, game) + + _update('id', 'game_url') + ret['live_url'] = self.live_url % ret['id'] + + status_map = { + '1': 'pregame', + '2': 'in_progress', + '3': 'final', + } + period_data = game.get('period_time', {}) + status_code = period_data.get('game_status', '1') + status = status_map.get(status_code) + if status is None: + self.logger.debug('Unknown %s game status code \'%s\'', + self.__class__.__name__, status_code) + status_code = '1' + ret['status'] = status_map[status_code] + + if ret['status'] in ('in_progress', 'final'): + period_number = int(period_data.get('period_value', 1)) + total_periods = int(period_data.get('total_periods', 0)) + period_diff = period_number - total_periods + ret['quarter'] = 'OT' \ + if period_diff == 1 \ + else '%dOT' if period_diff > 1 \ + else self.add_ordinal(period_number) + else: + ret['quarter'] = '' + + ret['time_remaining'] = period_data.get('game_clock') + if ret['time_remaining'] == '': + ret['time_remaining'] = 'End' + elif ret['time_remaining'] is None: + ret['time_remaining'] = '' + ret['overtime'] = ret['quarter'] if 'OT' in ret['quarter'] else '' + + _update('venue', 'arena') + + for ret_key, game_key in (('home', 'home'), ('away', 'visitor')): + _update('%s_score' % ret_key, '%s:score' % game_key) + _update('%s_wins' % ret_key, '%s:wins' % game_key) + _update('%s_losses' % ret_key, '%s:losses' % game_key) + _update('%s_city' % ret_key, '%s:city' % game_key) + _update('%s_name' % ret_key, '%s:nickname' % game_key) + _update('%s_abbrev' % ret_key, '%s:abbreviation' % game_key) + + # From API data, date is YYYYMMDD, time is HHMM + game_time_str = '%s%s' % (game.get('date', ''), game.get('time', '')) + try: + game_time = datetime.strptime(game_time_str, '%Y%m%d%H%M') + except ValueError as exc: + # Log when the date retrieved from the API return doesn't match the + # expected format (to help troubleshoot API changes), and set an + # actual datetime so format strings work as expected. The times + # will all be wrong, but the logging here will help us make the + # necessary changes to adapt to any API changes. + self.logger.error( + 'Error encountered determining game time for %s game %s:', + self.__class__.__name__, + game['id'], + exc_info=True + ) + game_time = datetime.datetime(1970, 1, 1) + + eastern = pytz.timezone('US/Eastern') + ret['start_time'] = eastern.localize(game_time).astimezone() + + self.logger.debug('Returned %s formatter data: %s', + self.__class__.__name__, ret) + + return ret