Unibet logo thumbnail

This blog will be longer, two NBA seasons longer, to be precise. It covers a more complex project than some others I have written about. It’s about sports betting and ranges from tweets and basketball to databases and browser automation.

Sports betting has always been an interest of mine, especially basketball. During my university days, I had a friend who was also into sports betting, and we often discussed our favorite teams and players. While I had made a few football bets for entertainment in the past, I never considered diving deeper into it.

However, as I’ve learned more about the industry, I’ve realized that sports betting is highly regulated in France, and it can be challenging to earn a profit.

An example of a bet

My friend had a unique approach to sports betting - he would front-run betting platforms by placing bets on injured NBA players. By acting quickly, he was able to take advantage of odds that had not yet been updated to reflect the latest injury news. This gave him an edge over the betting service, as the odds didn’t accurately reflect the probability of a particular outcome.

Let’s look at an example. Say Kevin Durant and Stephen Curry were both teammates on the Golden State Warriors. The odds for Kevin Durant scoring over 25 points might be 1.40. However, if Stephen Curry were to injure himself playing golf in the afternoon and be unable to play in the game, the 1.40 odd would now be associated with a higher probability outcome.

Without Curry, the team’s leading scorer, the odds of Durant scoring over 25 points are actually higher. Our friend could place a bet at the odds of 1.40 for a Kevin Durant 25-points game without Stephen Curry.

At first, I was skeptical of my friend’s approach because I thought these services were protected against it. However, I decided to work on some automation around this idea to see if it held up. As a result, I developed a system that could quickly gather and analyze injury data for NBA players and capitalize on opportunities for profitable bets.

In this blog, I’ll detail the process I went through to create this system, including how I sourced and analyzed data and how I developed the automation necessary to act on the betting opportunities. I hope this blog will be both informative and entertaining for those interested in sports betting or automation.

Goals

  • Retrieve recent tweets from a specified user account
  • Explore the NBA API to access relevant data and statistics
  • Acquire knowledge about browser automation techniques to optimize web scraping workflow
  • Increase your chances of winning against the house by mastering the art of playing the odds 😉

Process

  1. Retrieving Tweets

When it came to obtaining NBA-related injury updates, I found that only a handful of Twitter accounts were consistently and promptly tweeting about them. I needed to monitor their tweets in real-time to quickly identify injured players, but I struggled to find a reliable and fast data feed.

Initially, I considered using the Twitter API, but it required a domain name to receive tweet updates, which would have complicated matters unnecessarily. Given the urgency of the situation, I needed faster results.

Fortunately, I stumbled upon a GitHub library that reverse-engineered the Twitter API via JavaScript. It provided a simple function to pull the most recent tweets, and I ran this function in a loop with a few seconds’ delay. Although not the most optimal solution, it was a quick and effective way to identify a stream of injury-related tweets, complete with the corresponding content.


//initialize tweets memory
func TweetsInitialization(accountName string, pagesNumber int) (*Twitter, error) {

	var newTweets []twitterscraper.Tweet

	for tweet := range twitterscraper.GetTweets(context.Background(), accountName, pagesNumber) {
		if tweet.Error != nil {
			return nil, tweet.Error
		}
		//Only add tweet if not pinned
		if tweet.IsPin == false {
			newTweets = append(newTweets, tweet.Tweet)
		}
	}

	log.Println("Tweets initialized")
	return &Twitter{
		Tweets: newTweets,
	}, nil
}

//Check for new tweets
func (t *Twitter) GetLastTweets(accountName string, pagesNumber int) (*nba.Players, error) {

	var newTweets []twitterscraper.Tweet

	//Get 15 last tweets in a list
	for tweet := range twitterscraper.GetTweets(context.Background(), accountName, pagesNumber) {
		if tweet.Error != nil {
			return nil, tweet.Error
		}
		if tweet.IsPin == false {
			newTweets = append(newTweets, tweet.Tweet)
		}
	}

	if t.Tweets[0].Text != newTweets[0].Text {
		PlayerStatus, err := t.ProceedNewTweets(newTweets)
		if err != nil {
			return nil, err
		}
		t.Tweets = newTweets
		//log.Println("New Player/Status : ", PlayerStatus)
		return PlayerStatus, nil
	}
	t.Tweets = newTweets
	return nil, nil
}
  1. Identify the player name and the injury status

The objective of this task was to recognize both the name of the player and the kind of injury they suffered from. The challenge arose from the fact that the player’s name and condition could be presented in different formats across the tweets, which could make the identification process more difficult.

An example of an injury tweet

Fortunately, the format of the tweets remained consistent. However, some players had more complex names, which required manual exceptions, although this was only necessary for a few cases. The lastest twitter account we we were working with started every tweet by the name of the player, making things quite easy.

func ExtractPlayerFirstName(tweet string) string {
	wordsList := strings.Split(tweet, " ")
	playerFirstName := string(wordsList[0])
	return playerFirstName
}

func ExtractPlayerLastName(tweet string) string {
	wordsList := strings.Split(tweet, " ")
	playerLastName := string(wordsList[1])
	return playerLastName
}

For the injury status, I scouted for some words or combinations in the tweet content, the difficulty was that some injury flags were 1 word and some were 4. I feel like having just two more wording combinations would have made me think about something else than hardcoding them.

func CheckStatus(tweet string, status bool, position int) bool {
	if status == false {
	return false
	}
	wordsList := strings.Split(tweet, " ")
	var PositiveTwoWordsStatus []string
	var PositiveTwoWordsStatusPosition int
	for i, v := range wordsList {
		switch v {
		case "will":
			PositiveTwoWordsStatus = append(PositiveTwoWordsStatus, "will")
			PositiveTwoWordsStatusPosition = i
		case "play":
			PositiveTwoWordsStatus = append(PositiveTwoWordsStatus, "play")
		}
	}
	PositiveStatus := strings.Join(PositiveTwoWordsStatus, " ")
	if strings.Contains(PositiveStatus, "will play") == false || PositiveTwoWordsStatusPosition > position {
		return true
	}
	return false
}

func ExtractStatus(tweet string) (bool, int) {
	wordsList := strings.Split(tweet, " ")

	var FirstTwoWordsStatus []string
	var FirstTwoWordsStatusPosition int

	var SecondTwoWordsStatus []string
	var SecondTwoWordsStatusPosition int

	var ThirdTwoWordsStatus []string
	var ThirdTwoWordsStatusPosition int

	var FourthTwoWordsStatus []string
	var FourthTwoWordsStatusPosition int

	var FirstThreeWordsStatus []string
	var FirstThreeWordsStatusPosition int

	for i, v := range wordsList {
		//	log.Println(v)
		switch v {
		case "doubtful":
			return true, 0
		case "unlikely":
			return true, 0
		case "scratched":
			return true, 0
		case "won't":
			FirstTwoWordsStatus = append(FirstTwoWordsStatus, "won't")
			FirstTwoWordsStatusPosition = i
		case "play":
			FirstTwoWordsStatus = append(FirstTwoWordsStatus, "play")
		case "ruled":
			SecondTwoWordsStatus = append(SecondTwoWordsStatus, "ruled")
			SecondTwoWordsStatusPosition = i
		case "out":
			SecondTwoWordsStatus = append(SecondTwoWordsStatus, "out")
			SecondTwoWordsStatusPosition = i
			ThirdTwoWordsStatus = append(ThirdTwoWordsStatus, "out")
			ThirdTwoWordsStatusPosition = i
		case "listed":
			ThirdTwoWordsStatus = append(ThirdTwoWordsStatus, "listed")
			ThirdTwoWordsStatusPosition = i
		case "not":
			FourthTwoWordsStatus = append(FourthTwoWordsStatus, "not")
			FourthTwoWordsStatusPosition = i
		case "available":
			FourthTwoWordsStatus = append(FourthTwoWordsStatus, "available")
			FourthTwoWordsStatusPosition = i
		case "has":
			FirstThreeWordsStatus = append(FirstThreeWordsStatus, "has")
			FirstThreeWordsStatusPosition = i
		case "entered":
			FirstThreeWordsStatus = append(FirstThreeWordsStatus, "entered")
			FirstThreeWordsStatusPosition = i
		case "health":
			FirstThreeWordsStatus = append(FirstThreeWordsStatus, "health")
			FirstThreeWordsStatusPosition = i
		}
	}
	firstStatus := strings.Join(FirstTwoWordsStatus, " ")
	secondStatus := strings.Join(SecondTwoWordsStatus, " ")
	thirdStatus := strings.Join(ThirdTwoWordsStatus, " ")
	fourthStatus := strings.Join(FourthTwoWordsStatus, " ")
	FifthStatus := strings.Join(FirstThreeWordsStatus, " ")

	if strings.Contains(firstStatus, "won't play") {
		return true, FirstTwoWordsStatusPosition
	} else if strings.Contains(secondStatus,"ruled out") {
		return true, SecondTwoWordsStatusPosition
	} else if strings.Contains(thirdStatus, "listed out") {
		return true, ThirdTwoWordsStatusPosition
	} else if strings.Contains(fourthStatus, "not available") {
		return true, FourthTwoWordsStatusPosition
	} else if strings.Contains(FifthStatus, "has entered health") {
		return true, FirstThreeWordsStatusPosition
	}
	log.Println("No keywords")
	return false, 0
}
  1. Identify the team

The following step involved identifying the team of the injured player. Betting websites typically require users to select the sport or league, followed by the game and player(s) for setting bets. Rather than hard-coding the team names, I opted to connect to the NBA API for smoother updates, especially with frequent roster changes throughout the season. This approach saved us from the hassle of manually tracking and updating the teams for possibly over a hundred players.

Although it took some effort to find the official NBA API, I managed to extract the required schema using the player name and successfully retrieved their corresponding team.


func (p *PlayerStatus) ProceedPlayer() (string, error) {
  PlayerID, err := p.GetPlayerID()
  if err != nil {
    log.Fatal(err)
  }
  if PlayerID == "" {
    return "", nil
  }
  //log.Println("PlayerID obtained")

  PlayerTeamID, err := GetPlayerTeam(PlayerID)
  if err != nil {
    log.Fatal(err)
  }
  //log.Println("TeamID obtained")

  PlayerTeamName, err := GetTeamName(PlayerTeamID)
  if err != nil {
    log.Fatal(err)
  }
  switch PlayerTeamName {
  case "Atlanta Hawks":
    return "Atlanta", nil
  case "Brooklyn Nets":
    return "Brooklyn", nil
  case "Boston Celtics":
    return "Boston", nil
  case "Charlotte Hornets":
    return "Charlotte", nil
  case "Chicago Bulls":
    return "Chicago", nil
  case "Cleveland Cavaliers":
    return "Cleveland", nil
  case "Dallas Mavericks":
    return "Dallas", nil
  case "L.A. Clippers":
    return "LA Clippers", nil
  case "L.A. Lakers":
    return "LA Lakers", nil
  case "Washington Wizards":
    return "Washington", nil
  case "Golden State Warriors":
    return "Golden State", nil
  case "Portland Trail Blazers":
    return "Portland", nil
  case "New York Knicks":
    return "NY Knicks", nil
  case "Philadelphia 76ers":
    return "Philadelphie", nil
  }
  //log.Println("TeamName obtained")
  log.Println(PlayerTeamName)
  return PlayerTeamName, nil
}

The first module of our service was almost complete. We now have the name of the player and his team.

Code to get values for tweet
  1. Get the bet

Before launching the bet processing, we took one final step: checking out the bet we intended to place. With different players impacting the game in varying ways, we had to identify the players we wanted to bet on, the type of bets, and the amount of money we were willing to allocate to them.

My friend, being an avid NBA spectator, compiled a list of bets we should proceed with. He included details about the scope of the bets, such as points and rebounds, as well as the weight of each bet on our total bankroll. Meanwhile, I worked on a side algorithm that identified which bets we should include based on historical data of player injuries and team performance. But I will save that for another blog 😉

In terms of logistics, my friend needed an easy-to-use service to update the bet list frequently. Initially, I thought about a web interface that would allow him to upload a spreadsheet. But after some deliberation, I decided that a Google Sheet would be the ideal fit. With this choice, my friend could work on it easily, and I could access it through a public link.

A bet suspended notification from Unibet

I thus pulled the bets everyday at the same hour from the spreadsheet and pushed the data in a Postgres database I could reach when needed.


func (p *Players) StatusCheck() (*unibet.Bets, error) {

  //Connect to database
  db, err := gorm.Open("postgres", "host=xx.xx.xx.xxx port=xxxx user=POSTGRES dbname=DB_NAME password=PASSWORD")
  if err != nil {
    log.Println(err)
  }
  defer db.Close()

  var BetsList []*unibet.Bet
  for i := 0; i < len(p.Players); i++ {
    if p.Players[i].Status == false {
      log.Println("statut set as false")
    } else {
    tempPlayerName := p.Players[i].PlayerFirstName + " " + p.Players[i].PlayerLastName

    //Check player in database
    var DbBets []Injuries
    err = db.Where("Player_injured = ?", tempPlayerName).Find(&DbBets).Error
    if err != nil {
      return nil, err
    }
    if len(DbBets) == 0 {
      log.Println("Player NOT in betting list")
    } else {
      log.Println("Player IS in betting list")
      //Get TeamName
      TeamName, err := p.Players[i].ProceedPlayer()
        if err != nil {
          log.Println(err)
        }
        if TeamName == "" {
          return nil, nil
        }

      for _, v := range DbBets {
        Bet := p.Players[i].BetPick(v.Player_bet, TeamName, v.Bet_type, v.Details, v.Weight)
        if err != nil {
          return nil, err
        }
        p.Players[i].LoadHistoric(v.Player_bet, TeamName, v.Bet_type, v.Details, v.Weight)
        BetsList = append(BetsList, Bet)
      }
    }
  }
  }
  return &unibet.Bets{
    Bets: BetsList,
    }, nil
}
  1. Process the bet Alright, we now have all the necessary information to launch a bet. The next step is to automate the process. Since this project was initiated in Go, we are not allowed to use Selenium. Instead, I have opted for ChromeDP as our go-to tool for Go automation. Using it for the first time was a challenge, but I learned a lot from it. I encountered some glitchy modules from the Unibet front-end but managed to xpath my way out of it.

The process involved navigating to the Unibet NBA url, selecting the game with the team name, picking the bet we obtained through our spreadsheet-supplied database, inputting the bankroll multiplied by the weight of the bet, and confirming the bet. The entire process was completed within seconds!

func (b *Bet) PickSimpleBet(ctx context.Context, index string, mise string) {

	var res *runtime.RemoteObject
	var ress *runtime.RemoteObject
	var resss *runtime.RemoteObject
	var pitie *runtime.RemoteObject
	var pitiee *runtime.RemoteObject

	log.Println("Picking simple bet " + b.BetType + " for : " + b.PlayerToBetOn + " playing for : " + b.Team)

	//Select Bet on Player
	//Extend
	err := chromedp.Run(ctx,
		chromedp.Evaluate("window.scrollTo(0,document.body.scrollHeight);", &res),
		chromedp.Sleep(1*time.Second),
		chromedp.Evaluate("window.scrollTo(0,document.body.scrollHeight);", &res),
		chromedp.Sleep(1*time.Second),
		chromedp.Evaluate("window.scrollTo(0,0);", &ress),
		fullScreenshot(90, &buf),
	)
	WriteScreenshot(buf)
	log.Println("descendu puis REMONTÉ")

	var nodes []*cdp.Node
	if err := chromedp.Run(ctx,
		chromedp.Nodes(`//*[contains(text(),"`+b.BetType+`")]`+`/parent::*/following-sibling::*/div//sports-selections-selection/parent::*/parent::*/following-sibling::*`, &nodes, chromedp.AtLeast(0), chromedp.BySearch)); err != nil {
		log.Println(err)
	}
	if len(nodes) != 0 {
		log.Println("Extending..")
		err = chromedp.Run(ctx,
			chromedp.ScrollIntoView(`//*[contains(text(),"`+b.BetType+`")]`+`/parent::*/following-sibling::*/div//sports-selections-selection/parent::*/parent::*/following-sibling::*`, chromedp.BySearch),
			chromedp.Evaluate("window.scrollBy(0,-200);", &resss),
			chromedp.Click(`//*[contains(text(),"`+b.BetType+`")]`+`/parent::*/following-sibling::*/div//sports-selections-selection/parent::*/parent::*/following-sibling::*`, chromedp.BySearch),

			chromedp.ScrollIntoView(`//*[contains(text(),"`+b.BetType+`")]/parent::*/following-sibling::*/div//*[contains(text(),"`+b.PlayerToBetOn+`")]/parent::*/sports-selections-selection`, chromedp.BySearch),
			chromedp.Evaluate("window.scrollBy(0,-200);", &pitie),
			chromedp.Click(`//*[contains(text(),"`+b.BetType+`")]/parent::*/following-sibling::*/div//*[contains(text(),"`+b.PlayerToBetOn+`")]/parent::*/sports-selections-selection`, chromedp.BySearch),
		)
	} else {
		//Select bet on  player
		err = chromedp.Run(ctx,
			chromedp.ScrollIntoView(`//*[contains(text(),"`+b.BetType+`")]/parent::*/following-sibling::*/div//*[contains(text(),"`+b.PlayerToBetOn+`")]/parent::*/sports-selections-selection/parent::*/parent::*/parent::*/parent::*`, chromedp.BySearch),
			chromedp.Evaluate("window.scrollBy(0,-200);", &pitiee),
			chromedp.Click(`//*[contains(text(),"`+b.BetType+`")]/parent::*/following-sibling::*/div//*[contains(text(),"`+b.PlayerToBetOn+`")]/parent::*/sports-selections-selection`, chromedp.BySearch),
		)
	}
	err = chromedp.Run(ctx)//	fullScreenshot(90, &buf),

	//WriteScreenshot(buf)
	log.Println("Bet Picked")

	err = chromedp.Run(ctx,
		chromedp.SendKeys("/html/body//*/betting-slip-selection-card["+index+"]/div/betting-slip-selection-card-footer/div/div/div/betting-slip-stake-field/form/div/input", mise, chromedp.BySearch),
	)
	log.Println("Put bet weight successfully")
	if err != nil {
		log.Fatal(err)
	}
	//WriteScreenshot(buf)
}

To test the system, we started by placing 10-cent bets for over a month as we encountered multiple issues. However, as the project progressed, we were able to set up real bets with bigger amounts on multiple websites such as Unibet, Winamax, and Betclic.

Bet placed screenshot

In the latest version of the bot on Betclic, we opted for packaged bets as it was more time-efficient. However, this part of the development was one of the toughest as some user interfaces can be tricky, with iframe and progressively loading JavaScript code. I will share more about the difficulties we encountered in the “Results” section.

  1. Host our bot

The final step was to host our bot. Fortunately, ChromeDP allows us to run Chrome headless, so I decided to deploy the code to an Ubuntu server (I may have also tried on a Raspberry Pi, but I’m not certain). However, getting Chrome to work on a displayless server proved to be quite challenging. Although I was able to briefly get it working thanks to some online tips, I was unable to reproduce the success and eventually opted for a Microsoft server. It was a bit more expensive, but it got the job done and my friend was able to check it out too.

After successfully deploying the code, I took pleasure in utilizing Go’s cross-platform compiling capability. I also added a bash script to handle restarts in case of failure, which helped us navigate through numerous difficulties and errors. The attached screenshot illustrates this part, where the console on the right side was a complementary module from the bot’s first version. It processed the new NBA bets from Unibet in the morning to ensure they were available when we launched the bets. However, we later realized that it was overly complicated and frankly unnecessary, as we’d much rather have the process fail and restart.

Microsoft server to run the code

Results

With almost 300 bets made over two seasons, what’s the conclusion?

The project didn’t end with us losing money, but we didn’t earn a lot either, due to the high server hosting prices over the years. In fact, Unibet updated the odds faster than we could place our bets, so after the first season, we were forced to leave the platform.

But we didn’t give up. We tried to improve the bot for the next year by upgrading to a more powerful server, optimizing the code, and allowing for multiple bets to be placed at once. These efforts allowed us to place sub-9 seconds bets from the moment the injury was tweeted out. However, the platforms soon began to either delete our bets or increase the odds when they deemed our bets too risky. This significantly reduced the pool of available bets and made it costly to keep the bot running on a powerful server.

Given our daily activities and time constraints, we didn’t take note of every bet we made. Instead, we recorded only some of them. Based on that data, we would have made a reasonably significant profit, minus the high server fees that reached €56 per month. Though we started with a bankroll of over €2000, we each earned only around €50 from the endeavor, hardly the dream return for all the hard work we put in.

In conclusion, we had some success with our betting bot, but the rapidly updated odds and increasing risk prevention from the websites made it challenging to turn a profit. Nonetheless, I learned a lot from this experience and continued to refine my programming and automation skills.

Season 2020/2021 :

-621,22 € for almost 200 bets with a 65.52% winrate and an average odd of 1.73.

An early losing streak made it tough to get back on track. Betting on bankroll percentage amplified these trailing losses.

Season 2021/2022 :

+725,71 € for almost 100 bets worth a 60.61% winrate and an average odd of 1.88.

At the beginning of the season, we encountered significant struggles while trying to switch to Winamax. However, we eventually opted for Betclic, which proved to be a reliable choice. Unfortunately, some of our bets were not recorded, and I can’t quite recall why.

One particular incident that stood out to me was when we encountered an issue with the bot, leading to an inadvertent passing plus/minus bet on Jrue Holiday. The bot ended up processing the bet three times, placing over 300 euros on the game. Luckily, I watched the game and saw that Mr. Holiday did show up, passing the ball seven times for a bucket, making our bet a win!

This season was tough as we visibly faced new measures from the platforms but didn’t quite know which. I spent a lot of time adjusting the code and pushing for performance. The bot was even running on both Unibet and Betclic at some point.

Errors :

Working with front-end code brought numerous issues and errors for our bot. I’ll try to list a few.

  • Iframes: As a newcomer to automation, it took me some time to understand how to work with iframes.
  • Team names: Sometimes team names were listed as “Los Angeles Lakers,” other times as just “L.A Lakers” or “LA Lakers.” I had to set up a correspondence table or modify the xpath to make it more flexible. In French, “Nouvelle-Orléans” switched to “Nouvelle Orleans” randomly.
  • Player names: Similar to team names, some player names had tough spellings (such as Devonte’ Graham) and the websites did not always use the official spelling. Sometimes the last name of the player was listed first, and sometimes it was their first name.
  • Missed bets: Launching a bet meant stopping the main process, which sometimes caused us to miss certain Twitter updates. For example, if there was a tweet about LeBron James being doubtful 3 seconds after a tweet about Kevin Durant being ruled out, we would miss it. To address this, I set up a subprocess to keep listening to tweets while the main process was running.
  • Bet labels: The spelling of some bet labels was quite random. For example, we faced variations such as “plus de 10 points” and “+ de 10 points” depending on the day.
  • Bet display: Depending on the number of bets in a subcategory, they were sometimes displayed in a rolling menu. I had to incorporate xpath checks to handle this.
  • Amount too low: This was a particularly challenging issue, as I did not have any screenshotting functionality set up on the production environment, and it never happened during my tests. I discovered it as it happened in real time one day.
A bet suspended notification from Unibet

Review

Looking back at my experience working on a betting tool for NBA games, there are a few things I would have done differently to improve its functionality and usability.

First and foremost, I would have implemented error logging much earlier in the project lifecycle. Issues were happening too frequently and it was often difficult to identify and resolve them. It wasn’t until I introduced automatic screenshotting features that the product became more stable. However, I wish I had spent more time deploying logging features, especially considering that NBA games happen during the night and injury updates are broadcasted around 11pm in France, making it challenging to keep an eye on the bot. Although I did implement Go code logging and saved the bets of the day to ensure that the bets were available and that it was indeed an error from the bot, it could have been better earlier.

Another area for improvement would have been my approach to navigating the Winamax website. It was an older website with multiple iframes and messy code, and I spent a lot of time struggling with it when we left Unibet. In retrospect, I should have let it go and switched earlier to Betclic, which would have provided a more modern website experience.

Lastly, including some Javascript or API retro-engineering skills could have helped me circumvent some blocking solutions that were deployed by these websites, and I would have learned a lot in the process.

Despite these challenges, working on this tool was one of my biggest projects at the time, and I learned a lot through two NBA seasons. ChromeDP and automation were both very pleasant to work with, even if I did come across some mind-bending bugs. It was also entertaining to watch a few NBA games when I knew the bot had placed bets on them, and it felt satisfying to beat these powerhouses, even if it was just for a few bucks.

As always, feel free to reach out !