Automatic Markdown Post Generation from ESPN Fantasy Football API

Trying to keep people engaged in my fantasy football league is an ongoing struggle. Because of this I started a ghost blog on for my league to have some weekly posts go out to generate some engagement from the other league members. I also did not want to spend a lot of time writing posts every week so I figured automating the data gathering aspect and making a few predictions using ESPN's API (which is undocumented for whatever reason) would be a good way to go.

ESPN API

As I mentioned before ESPN fantasy does have a fantasy football API, but it is not really documented and changes quite often (just a few days ago they added pagination to their results breaking a bunch of code). However as of this writing (2/10/2021) the following endpoint works and is what I used.

leagueID=LEAGUEID
w=WEEKNUMBER
scoreboard=requests.get('http://fantasy.espn.com/apis/v3/games/ffl/seasons/2020/' \
'segments/0/leagues/{}?view=mBoxscore&scoringPeriodId={}&matchupPeriodId={}'.format(leagueID,w,w))

If we set up the scoringPeriodID and the matchupPeriodID as the same we can pull the scores for that week as well as the projections.

The values returned provide us with both the team names, the actual score, the projected score and per player statistics. There are also other endpoints, the most useful which is the per player projections.

import requests

url = "https://fantasy.espn.com/apis/v3/games/ffl/ \
seasons/2020/segments/0/leagues/{}?view=kona_player_info".format(leagueID)

headers = {
  'X-Fantasy-Filter': '{"players": \
    {"filterStatus":\
      {"value":["FREEAGENT","WAIVERS"]},\
      "filterSlotIds":{"value":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,23,24]},\
      "filterRanksForScoringPeriodIds":{"value":[2]},\
      "sortPercOwned":{"sortAsc":false,"sortPriority":1},\
      "sortDraftRanks":{"sortPriority":100,"sortAsc":true,"value":"STANDARD"},\
      "filterRanksForRankTypes":{"value":["PPR"]},\
      "filterRanksForSlotIds":{"value":[0,2,4,6,17,16]},\
      "filterStatsForTopScoringPeriodIds":{"value":2,\
                    "additionalValue":["002020","102020","002019","1120202","022020"]}}}',\
  'Cookie': 'region=unknown; _dcf=1'
}

response = requests.request("GET", url, headers=headers)

I don't really use this endpoint for the Summary and Preview posts, but it is interesting for making my own projections and similar. Using the Dev Console on any browser you can figure out the necessary values for the X-Fantasy-Filter dictionary and they are pretty self-explanatory.

Summary Post

The first post I wanted to automate was the summary post for the previous weeks matchups. For each match I need to pull the scores and for a fun stat also look at all the possible team combinations for each roster and figures out if the player actually picked a decent team or if he had done better randomly selecting his team.

First we generate a couple of dictionaries to help with generating the post.

sc_data=scoreboard.json()

#Position ID to description
pos_dict={0:'QB',2:'RB',4:'WR',6:'TE',23:'FLEX',16:'D/ST',17:'K',20:'BNCK'}

#Allowable number of players in position per active roster
Rs={0:1,2:2,4:2,6:1,23:1,16:1,17:1}

#Team names to team id
team_map={itm['id']:itm['location']+' '+itm['nickname'] for itm in sc_data['teams']}
iteam_map = {v: k for k, v in team_map.items()}

#My league has three dvisions
divs={0:'East',1:'West',2:'Mid'}

#What division each team is in
team_div={x['id']:divs.get(x['divisionId']) for x in sc_data['teams']}

Now that we have the dictionary we will get each team, their scores, and all the possible team combinations along with their possible scores.

#Dictionary to hold each teams information
gscores={}

#The match id for that week
gno=1
#Iterate over each game in the schedule
for s in sc_data['schedule']:
    if s['matchupPeriodId']==w:
        print('---------')
        print("home: {} away {}".format(s['home']['teamId'],s['away']['teamId']))
        print('---------')
        tdict={}
        #Iterate over the home and away teams
        for ttype in ['home','away']:

            team=s[ttype]
            print(ttype.upper())
            #Store both the team roster and and the active roster
            aroster=team['rosterForMatchupPeriod']
            roster=team['rosterForCurrentScoringPeriod']
            #Empty array for lineup
            lp=[]
            for e in roster['entries']:
                pscore=e['playerPoolEntry']['appliedStatTotal']
                pfname=e['playerPoolEntry']['player']['fullName']
                pslots=e['playerPoolEntry']['player']['eligibleSlots']
                pslot=e['lineupSlotId']
                print("{:.2f} {} Slot:{}".format(pscore,pfname,pslot),pslots)
                lp.append([pscore,pfname,pslot,pslots])

            #Create a dataframe holding each player, the slot they are in,
            # and the possible positions they could play in
            lpd=pd.DataFrame(lp,columns=['Score','Name','Slot','Slots'])
            lpd['SlotPos']=lpd.Slot.map(pos_dict)
            lpd.SlotPos.fillna('Unknown',inplace=True)
            posln={}

            for k in Rs:
                posln[k]=lpd[lpd.Slots.apply(lambda x:k in x)].index.values

            #Tarr store the index of lpd based on the quantities stored in the Rs
            #dictionary previously stored
            tarr=[]
            for i,k in Rs.items():
                for ii in range(0,k):
                    tarr.append(list(posln[i]))

            #Create an empty array that contain each possible lineup's scores
            lpscore=[]

            #Generate each combination based on the arrays in tarr
            #Keep only the ones that have 9 distinct players
            for lpos in itertools.product(*tarr):
                if len(set(lpos))==9:
                    lpscore.append(lpd.loc[list(lpos)].Score.sum())

            tdict[ttype]={'score':roster['appliedStatTotal'],'roster':lpd,'teamid':team['teamId'],'pos':lpscore}
        gscores[gno]=tdict
        gno=gno+1

For each team roster our lpd will look like this (Unknown is used for the IR spots)

Score Name Slot Slots SlotPos
0 0 Miles Sanders 21 [2, 3, 23, 7, 20, 21] Unknown
1 14 Travis Kelce 6 [5, 6, 23, 7, 20, 21] TE
2 0 Courtland Sutton 21 [3, 4, 5, 23, 7, 20, 21] Unknown
3 6.4 A.J. Brown 4 [3, 4, 5, 23, 7, 20, 21] WR
4 2.4 Leonard Fournette 2 [2, 3, 23, 7, 20, 21] RB
5 5.6 Le'Veon Bell 23 [2, 3, 23, 7, 20, 21] FLEX
6 6.7 DeVante Parker 20 [3, 4, 5, 23, 7, 20, 21] BNCK
7 10.5 Julian Edelman 20 [3, 4, 5, 23, 7, 20, 21] BNCK
8 28.18 Josh Allen 0 [0, 7, 20, 21] QB
9 22.4 Robby Anderson 20 [3, 4, 5, 23, 7, 20, 21] BNCK
10 23.3 Nyheim Hines 20 [2, 3, 23, 7, 20, 21] BNCK
11 6.4 Boston Scott 2 [2, 3, 23, 7, 20, 21] RB
12 6.5 Steven Sims Jr. 20 [3, 4, 5, 23, 7, 20, 21] BNCK
13 17 Saints D/ST 16 [16, 20, 21] D/ST
14 22.06 Ben Roethlisberger 20 [0, 7, 20, 21] BNCK
15 7 Rodrigo Blankenship 17 [25, 17, 20, 21] K
16 4.5 Mike Gesicki 20 [5, 6, 23, 7, 20, 21] BNCK
17 4.9 Tim Patrick 4 [3, 4, 5, 23, 7, 20, 21] WR

and our tarr looks like this

[[8, 14],
 [0, 4, 5, 10, 11],
 [0, 4, 5, 10, 11],
 [2, 3, 6, 7, 9, 12, 17],
 [2, 3, 6, 7, 9, 12, 17],
 [1, 16],
 [0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 16, 17],
 [13],
 [15]]

A quick check makes sure that it matches what we would think. Since we have all the information available we can generate histograms that will show us the performance of each lineup.

heading=[]
#Seaborn setup
sns.set_theme(style="ticks",font_scale=1.2, color_codes=True)
tds=pd.DataFrame([],columns=['team'])
#Make a nice datframe with all the teams (for our box plot)
all_rosters=pd.DataFrame([])
for i,g in gscores.items():
    print('GAME '+str(i))
    home=team_map.get(g['home']['teamid'])
    away=team_map.get(g['away']['teamid'])
    home_score=g['home']['score']
    away_score=g['away']['score']
    ##WE create a sybtutke
    heading.append('{}: {:.1f} vs {}: {:.1f}'.format(home,home_score,away,away_score))
    print('{}: {:.1f} vs {}: {:.1f}'.format(home,home_score,away,away_score))
    f, (ax1, ax2) = plt.subplots(1, 2, sharey=False,figsize=(15,5))

    #pd.Series(g['home']['pos']).hist(ax=ax1,bins=15, grid=False,facecolor='b',edgecolor='k')
    sns.histplot(g['home']['pos'],ax=ax1,bins=15,stat="probability")
    ax1.axvline(g['home']['score'],color='r')
    ax1.set_title('{}:{:.1f}'.format(home,home_score))

    #pd.Series(g['away']['pos']).hist(ax=ax2,bins=15, grid=False,facecolor='b',edgecolor='k')
    sns.histplot(g['away']['pos'],ax=ax2,bins=15,stat="probability")
    ax2.axvline(g['away']['score'],color='r')
    ax2.set_title('{}: {:.1f}'.format(away,away_score))

    td=pd.DataFrame(g['home']['pos'],columns=['score'])
    td.insert(column='team',value=home,loc=0)
    td.insert(column='sc',value=home_score,loc=0)
    tds=tds.append(td)
    all_rosters=all_rosters.append(g['home']['roster'])

    td=pd.DataFrame(g['away']['pos'],columns=['score'])
    td.insert(column='team',value=away,loc=0)
    td.insert(column='sc',value=away_score,loc=0)
    tds=tds.append(td)
    plt.savefig('Week{}/g{}_prob.png'.format(w,i))
    all_rosters=all_rosters.append(g['away']['roster'])

So we will end up with each matchup histogram. For example:

Finally for a overall view of each teams performance we can do the following:

fb,axbp = plt.subplots(figsize=(15,10))

sns.boxenplot(x="team", y='score', data=tds, ax=axbp)
sns.stripplot(data=tds[['team','sc']].drop_duplicates(), x="team", y="sc",color='r',jitter=0,size=10)
axbp.tick_params(axis='x', labelrotation=45)
axbp.set_title('Team Possibile Scores Week 1');
axbp.set(xlabel='Team', ylabel='Possible Score');
plt.savefig('Week{}/overall_prob.png'.format(w))

Which gives us the following plot, with the red dot representing the actual team score

Finally we calculate some fun stats (team with the top best possible score and team with the worst best possible score. We also calculate the league MVP and LVP (in terms of points scored versus other players in the position).

bsc=tds.groupby('team').max()
bsc.columns=['Actual Score','Best Possible Score']
bsc.index.name=''
bmax=bsc.idxmax().to_frame()
bmin=bsc.idxmin().to_frame()

perf=[]
for gname, gp in all_rosters[['Name','Score','SlotPos']][~all_rosters['SlotPos'].isin(['Unknown','BNCK'])].groupby('SlotPos'):
    sc=StandardScaler()
    gp=gp.set_index('Name')
    xs=sc.fit_transform(gp['Score'].values.reshape(-1, 1))
    gp['Scale']=xs
    pmin=gp.loc[gp.Score.idxmin()]
    pmax=gp.loc[gp.Score.idxmax()]
    perf.append([pmax.name,pmin.name,pmax.Scale,pmin.Scale])
perfdf=pd.DataFrame(perf,columns=['Max_Name','Min_Name','MaxN','MinN'])
LVP=perfdf.loc[perfdf.MinN.idxmin].Min_Name
MVP=perfdf.loc[perfdf.MaxN.idxmax].Max_Name

I also added a random quote as a subheading for the post

f = open('quotes.txt', 'r')
txt = f.read()
lines = txt.split('\n.\n')
rand=random.choice(lines)

Finally this can the moved to a markdown file that I can copy paste into ghost and then upload each plot as necessary.

filename='Week{}/stat_post.md'.format(w)
with open(filename, 'w', encoding='utf-8') as f:
    f.write("# Week {} Stats \n".format(w))
    f.write("> {}  \n".format(rand))
    f.write('## Team Performance \n')
    f.write('Team with the highest score: {}  \n'.format(bmax.loc['Actual Score'][0]))
    f.write('Team with the highest best score: {}  \n'.format(bmax.loc['Best Possible Score'][0]))
    f.write('Team with the lowest score: {}  \n'.format(bmin.loc['Actual Score'][0]))
    f.write('Team with the lowest best score: {}  \n\n'.format(bmin.loc['Best Possible Score'][0]))
    f.write(bsc.to_markdown())
    f.write('\n\n')
    f.write('Week {} MVP: {}  \n'.format(w,MVP))
    f.write('Week {} LVP: {}  \n'.format(w,LVP))
    for h in heading:
        f.write('## {} \n'.format(h))
    f.write(' \n')
    f.write('## Overall Team Performance \n')

And that is it. After they get imported into ghost I go ahead and write whatever blurb I want under each teams heading. It works pretty well and saves me a bunch of time. I have also modified this code to run as a DAG on airflow.

Jupyter Notebooks found here