ESPN Focus Game mode

The Problem

I do not really watch sports that much, but sometimes will help friends pick games for a "Pick Em" league. It is fun to keep tabs on the games and see how the picks are doing.

However, if you go to ESPN's scoreboard, you can choose the "Top 25" or "All FBS Div I" games. And there are a lot of games:

the full ESPN page of games

So if you only care about the 10 games selected for the Pick Em league, there is a lot of "noise" with the other games that are displayed. Which is just the type of situation a bored developer can create a script and solve this "problem."

Defining our goal

The general idea here is:

Only show the games I am interested in, remove almost everything else

First step to solution: Filter out other games

If we use the inspect tool, we can see the HTML element for each game. Looks like this:

sample html showing the structure

Clearly, we are going to need a way to identify the games we care about. We are going to have to provide an array of teams we want to watch. Something like this:

const gamesInterestedIn = [
    "Kentucky",
    "Alabama",
    "Georgia",
    "Rutgers",
    "West Virginia",
    "Tulane",
    "Illinois",
    "Arkansas State",
    "Oregon State",
    "James Madison",
    "LSU",
    "BYU",
]

Now we need to figure out a way to identify each game. If you look at the HTML screenshot above, you might notice the <section> has an id attribute. But, we only know what team we are interested in, and after finding that id I searched to see if the id for each game was repeated anywhere, and it is not.

It's ok, we will just have to be clever and figure out a way to pull this off.

Let's start adding to our script. To start, I know we will need to find a team name, get the section id as the gameId and create an array of gameIds. There are also a couple functions that we will need to keep things more readable. Here's things to start:

const gameIds = new Set;
const q = (selector) => document.querySelectorAll(selector);
  1. All we are doing is creating a gameIds Set, so it will only have unique values (the main feature of using a Set).
  2. creating a shorthand q function which is just document.querySelectorAll() (but that is a lot to type, so q, is literally less to type and read).

With that we can now figure out how to get the team, and then the section id.

If you look close enough in the HTML screenshot above, you might notice the name of the team is in a div with class ScoreCell__TeamName on it. We can now use our q function to target that.

q('.ScoreCell__TeamName')

This returns a NodeList of a lot of elements. Not shown in the HTML screenshot, there is div wrapping the main scores area with a class of Card we will need to add this to our selector in order to avoid targeting games in the Header Top Bar of Top 25 games.

q('.Card .ScoreCell__TeamName')

Also, a NodeList does not allow us to use the JS Array functions filter find map, so we want to create an array instead. We can do something like this:

[...q(".Card .ScoreCell__TeamName")]

Now that we have an array, we want to find the elements that match our selected teams. To do that we will need to use the .find() function.

// Note, the team name in the array of games we want to watch MUST match the name of the team in the Scoreboard list.
gamesInterestedIn.forEach(name => {
 const el = [...q(".Card .ScoreCell__TeamName")]
      .find(el => el.textContent == name);
}

And then we will need to find the <section> parent of this game, and get the id from that element. Usually we can just use the .closest() function to target the parent with .closest('section'), but here and not actually shown in the HTML screenshot above there is a <section> within the <section> we want to target. This means doing:

// wont work, not the right section
el.closest('section');

Will not be enough on its own to get our desired parent element. We will need our function to look like this:

// will work, because we get first parent section, its div parent, and the section parent we want
const getParent = (el) => el.closest('section').closest('div').closest('section');

Then we can get the id from that with another function (for readability) like this:

const getParentId = (el) => getParent(el).getAttribute('id');

Which means this part of the code is going to look like this to get the gameIds into an array. Like this:

gamesInterestedIn.forEach(name => {
    const el = [...q(".Card .ScoreCell__TeamName")]
      .find(el => el.textContent == name);
      gameIds.add(getParentId(el));
})

Second Step to solution: Hide the things we do not want to see.

JS out of the box has many functions to target elements and then manipulate them. getElementById or getElementsByClassName or querySelectorAll (and others) are often what we reach for to target things. Since we already have a shorthand q function, I am going to try to use that as much as possible to keep reading and amount of code minimal as possible. Plus querySelectorAll is kind of the most flexible to use to target elements anyway.

Again, the HTML screenshot above does not show all the HTML of the page, so you are going to have to take my word for it on some of these class names. Here are some classes that I identified as being ripe for removal:

const classesToHide = [
    '.PageLayout__RightAside',
    '.Scoreboard__Header',
    '.Ad--banner',
    '.sponsored-content',
    '.HeaderScoreboardWrapper',
    '.page-footer-container',
    '.Site__Header__Wrapper',
    '.Scoreboard__Callouts',
    '.Scoreboard__Column--3'
]

Since I put these classes into an array, I am thinking I can just loop over them, use the q function to target them, then either .remove() the element or hide it.

Initially I thought .remove() would work, and technically it does. However, ESPN pushes out updates to your browser, which means it is looking for places to hook on to and if I remove the elements from the page, ESPN throws an error and stops showing any scores.

This means I want to hide them with css instead, because all I really care about is there being less "noise" on the page. I simply dont want to see it.

So to do that, we will create another function that will set the css display property of any element to none, effectively removing it from my sight. This should do the trick:

const setDisplayNone = (els) => els.forEach(el => el.style.display = 'none');

Now we just need to loop our classes and pass the el to our setDisplayNone function; something like this:

classesToHide.forEach(cls => {
  setDisplayNone(q(cls));
})

For anyone paying close attention, we still have not filtered out the games we dont want see. Brownie points for those who noticed. Lets tackle that now.

I kind of did leave that bit out on purpose, to make this point.

So far with the classes in classesToHide parts of the page will now be hidden, but there are a couple things that will be trickier to hide.

  1. The ad section above the scores
  2. Games that have happened on other days. These are in sections with class of gameModules

For #1, the ad section does not have an id or class that we can easily target. This is where using querySelectorAll is helpful. We can write any css selector here and it should target them just fine.

If you look at the HTML of the page, you might see that the ad section is the first child of a div that has a class of pageContent, but you should also notice that the Scoreboard is also a child of that .pageContent div.

This means that .pageContent > div would get rid of both. But we need only the first one. So our selector will need to be .pageContent > div:first-child.

Great that was easy.

For #2, it's not that bad, but we need to use the :not() selector which is kind of neat. We really only want to watch todays games... because the games on previous days are done, and we dont need to see finished games. And games in the future will not update as we are watching, so we kind of only care about todays games. (Or maybe you want to do it different, this is my script so my rules :D )

If I q('.gameModules') I get all the different days divs. But I just want to today, which ESPN has set to be the first div in this list. So using the :not() selector with the :first-child selector we can target all the days besides today.

.gameModules:not(:first-child)

And bam, all other days are hidden. Noice.

Third Step in solution: Hide the other games already

For this one, things are a little trickier, we likely wont be able to write a css selector to target only the games we dont want to see. We do know that we can target the .Scoreboard nodes (each one is a game basically). Which means we will likely just loop over them and see if the id is in our gameIds array and if NOT, we will hide that node.

One thing to note, so far all the classesToHide have been a single string. That's nice because this is really easy to think about and update if ever come back to this and do anything else with it. But since we wont be able to write this query selector as a single string, we may need to make an adjustment to handle "non single string" cases. We can do this with a simple if statement. Like this:

classesToHide.forEach(cls => {
    if(typeof cls === 'string') {
        setDisplayNone(q(cls))
    } else {
        
    }
})

This means we can add something other than a string to our classesToHide array, and handle it differently. (Note, this is not the best way to handle this, but for this specific script, it'll be fine).

We know we will need some type of selector to get the .Scoreboard nodes. And someway to loop over the games, lookup the id and then either setDisplayNone or do nothing. Making me think an object with a class property and a forEach property makes sense. Then any time you need to add a specific case, can just add a new object and then the code should still handle it fine.

Something like this:

const classesToHide = [
    {
        class: '.Scoreboard',
        forEach: (els) => els.forEach(game => {
            if(!gameIds.has(game.getAttribute('id'))) {
                setDisplayNone([game]);
            }
        })
    },
    // truncated for blog
].forEach(cls => {
    if(typeof cls === 'string') {
        setDisplayNone(q(cls))
    } else {
        cls.forEach(q(cls.class));
    }
})

This will loop over classesToHide checks to see if it is a simple string selector, if so handles setting things to "display none".

If not a simple string, uses the class (or selector in this case) and passes the elements to the forEach defined for the object. Then you are free to handle that condition however is needed.

This keeps this script pretty simple really, but still allows for enough customization if ever needed to.

The whole script is available in this Github gist.

The final result

And if you run it in the console of this week, you should end up with something that looks like this:

All things removed from ESPN and only the games I want to see show up.

Now if I want to do this to filter the games, I just need to update the gamesInterestedIn array with the team names I want to see, execute the script in the console and bam, a lot less noise and minimal scrolling to see the games I want to see.

I'm sort of surprised that ESPN doesn't have a feature for this. Sure it would take a second to select the games you want, but the ease of following only the games you want is a nice experience in my opinion. Can still show ads, so not seeing it as a loss there. Who knows.

Sometimes ESPN refreshes the whole scoreboard node, I'm guessing when a game starts or finishes. So sometimes you have to re-run the script to filter all the things out again. But should be able to put a cursor in the browser console, press the up arrow and hit enter and be back in business.

That's about it, this was kind of fun, pretty easy, but thought it might be interesting to someone else.