Twitter Bot for NFL Octopi
October 10, 2025
Twitter Bot
An Octopus in the NFL is when one player gets both the touchdown, and the two point conversion. This term was coined by Mitch Goldich. Please check out his blog, as without him this would not have been created.
Twitter Bot
Purpose
The intention is to tweet whenever an NFL player gets an octopus, or whenever the intended receiver who dropped the ball was the player who scored the touchdown. @NFLOCTOBOT is the handle. If you want to support me and keep the bot running, you can donate.
Process
Creating a Twitter bot is pretty straightforward. After creating an account, you can go to the Twitter developer console to fetch all necessary tokens for auth, allowing access to API endpoints that are very well documented. Because I used NodeJS, I was able to install a package which aids in formatting the OAuth request parameters, along with some other helper functions which wrap requests. Overall after instantiating the client, making requests was pretty simple.export const twitterBaseUrl = `https://api.x.com/2` export const getTwitterClient = async () => { return new TwitterApi({ appKey: process.env.X_API_KEY as string, appSecret: process.env.X_API_KEY_SECRET as string, accessToken: process.env.X_ACCESS_TOKEN as string, accessSecret: process.env.X_ACCESS_TOKEN_SECRET as string }); } ... await twitterClient.post(`${twitterBaseUrl}/tweets`, body)
Twitter rate limits posts to 17 per 24 hours on the free tier. I don't anticipate this being a problem for tweeting about octopi.
ESPN Hidden API
In order to get data on octopi, I'm using ESPN's hidden API, which ironically is documented better than many other APIs I've worked with. I'm performing HTTP short polling every minute in order to get up to date data, but also not overload ESPN's servers. The business logic is as follows- Fetch all games for the current day
- Fetch all plays for each game
- Filter out scoring plays which are not two point conversions
- Filter out scoring plays where the pat scorer, or intended pat scorer, was not the same as the touchdown scorer
- Gather specifics about the player and play information
- Save statistics and play information to the database for leaderboard, and deduplication purposes
- Post to Twitter
Data Modeling
In my efforts to process ESPN data efficiently, I created my own notion of models for things such as games, scoring plays, athletes, and many other things. My data models are belowexport class Game { constructor(public gameId: number, public scoringPlays: ScoringPlayInformation[] = []) {} public deduplicateProcessedPlays(playIds: number[]) { const gameScoringPlays = this.scoringPlays?.length const playIdsSet = new Set(playIds) this.scoringPlays = this.scoringPlays?.filter(scoringPlay => { return !playIdsSet.has(scoringPlay.id) }) const unprocessedGameScoringPlays = this.scoringPlays.length console.log( `Removed ${gameScoringPlays - unprocessedGameScoringPlays} already processed scoring plays from game ${this.gameId}` ) } public filterScoringPlays() { const scoringPlays = this.scoringPlays?.filter((scoringPlay) => { return scoringPlay.isOctopus() || scoringPlay.isMissedOctopus() }) console.log(`Found ${scoringPlays.length} new plays that are either octopi, or missed octopi`) return scoringPlays } }
export class ScoringPlayInformation { constructor( public id: number, public participants: Athlete[], public pointAfterAttempt: PointAfterAttempt, public shortText: string, public text: string, public wallclock: Date, public octopusScorer?: Athlete, public octopusMissedAthlete?: Athlete, ) {} public isOctopus() { if (this.pointAfterAttempt.isTwoPointAttempt && this.pointAfterAttempt.success && this.pointAfterAttempt.scorer) { const tdScorer = this.participants.find((participant: Athlete) => { return participant.type === SCORER_TYPE.TD_SCORER }) return (tdScorer && this.pointAfterAttempt.scorer.id === tdScorer.id) } return false } public isMissedOctopus() { if (this.pointAfterAttempt.isTwoPointAttempt && !this.pointAfterAttempt.success && this.pointAfterAttempt.scorer) { const tdScorer = this.participants.find((participant: Athlete) => { return participant.type === SCORER_TYPE.TD_SCORER }) return (tdScorer && this.pointAfterAttempt.scorer.id === tdScorer.id) } return false } public async saveOctopusToDatabase(datasource: DataSource) { await datasource.transaction(async (entityManager: EntityManager) => { const scoringPlayRepository = entityManager.getRepository(ScoringPlay) const octopusCountRepository = entityManager.getRepository(OctopusCount) const playerOctopusCountRepository = entityManager.getRepository(PlayerOctopusCount) if (this.octopusScorer) { const playerId = this.octopusScorer?.id const play = new ScoringPlay(this.id) await scoringPlayRepository.save(play) await octopusCountRepository.increment({id: 1}, 'count', 1) const playerOctopusCount = await playerOctopusCountRepository.findOneBy({id: playerId}) if (!playerOctopusCount) { const newPlayerOctopusCount = new PlayerOctopusCount(playerId, 1) await playerOctopusCountRepository.save(newPlayerOctopusCount) } else { playerOctopusCount.octopusCount += 1 await playerOctopusCountRepository.save(playerOctopusCount) } console.log(`Successfully saved the playId ${this.id}`) console.log(`Successfully updated player octopus count for player with id ${playerId}`) console.log(`Successfully updated global octopus count`) } }) } public async saveFailedOctopusScoringPlayToDatabase(datasource: DataSource) { const scoringPlayRepository = datasource.getRepository(ScoringPlay) const scoringPlay = new ScoringPlay(this.id) await scoringPlayRepository.save(scoringPlay) } public async postFailedOctopusToTwitter(twitterClient: TwitterApi, datasource: DataSource) { const octopusCountRepository = datasource.getRepository(OctopusCount) const octopusCount = await octopusCountRepository.findOneBy({id: 1}) if (this.octopusMissedAthlete && octopusCount) { await postFailedOctopusToTwitter(twitterClient, this.shortText, this.octopusMissedAthlete?.firstName, this.octopusMissedAthlete?.lastName, octopusCount?.count + 1) } } public async postOctopusToTwitter(twitterClient: TwitterApi, datasource: DataSource) { let playerOctopusCount = 0 let globalOctopusCount = 0 let playerOctopusRanking = 0 let playerOctopusRankingTiedWith = 0 await datasource.transaction(async (entityManager) => { if (this.octopusScorer) { const playerOctopusCountRepository = entityManager.getRepository(PlayerOctopusCount) const octopusCountRepository = entityManager.getRepository(OctopusCount) const octopusCount = await octopusCountRepository.findOneBy({id: 1}) const playerOctopus = await playerOctopusCountRepository.findOneBy({id: this.octopusScorer.id}) if (playerOctopus && octopusCount) { globalOctopusCount = octopusCount.count playerOctopusCount = playerOctopus.octopusCount playerOctopusRanking = await playerOctopusCountRepository.count({where: {octopusCount: MoreThan(playerOctopusCount)}}) + 1 playerOctopusRankingTiedWith = await playerOctopusCountRepository.count({where: {octopusCount: Equal(playerOctopusCount)}}) - 1 } } }) if (this.octopusScorer) { return await postOctopusToTwitter( twitterClient, this.shortText, this.octopusScorer?.firstName, this.octopusScorer?.lastName, playerOctopusCount, globalOctopusCount, playerOctopusRanking, playerOctopusRankingTiedWith ) } } public async populateOctopusPlayerInformation() { this.octopusScorer = this.pointAfterAttempt.scorer } public async populateFailedOctopusPlayerInformation() { this.octopusMissedAthlete = this.pointAfterAttempt.scorer } }
export class PointAfterAttempt { constructor(public success: boolean, public isTwoPointAttempt: boolean, public scorer?: Athlete) { } }
export class Athlete { constructor(public firstName: string, public lastName: string, public id: number, public type: string) {} }If you have critiques of my data model, well so do I. This bot was made over the course of a couple of days, and was a fun side project. I'm not particularly invested in making the code as perfect as possible. In particular, I feel that some of the logic is overcomplicated, and posting to Twitter should not be encompassed within the data model of a scoring play, but rather externally defined and parameterized with a scoring play.
Anti-Corrpution Layer
In order to populate my data models, I have an anti-corruption layer which helps to query the ESPN API and create said models.export const getScoringPlayInformation = async (gameId: number, scoringPlayIds: number[]) => { if (scoringPlayIds) { return await Promise.all(scoringPlayIds.map(async (scoringPlayId) => { const url = `https://sports.core.api.espn.com/v2/sports/football/leagues/nfl/events/${gameId}/competitions/${gameId}/plays/${scoringPlayId}` const result = await fetch(url) const scoringPlayInformationResponse: ScoringPlayInformationResponse = await result.json() return scoringPlayInformationResponse })) } } export const getScoringPlayAthletes = async (scoringPlay: ScoringPlayInformationResponse) => { const athletes: Athlete[] = [] if (scoringPlay?.participants) { await Promise.all(scoringPlay?.participants.map(async (participant) => { const athleteResponse = await getAtheleteInformation(participant.athlete.$ref) if (athleteResponse) { const athlete = new Athlete(athleteResponse.firstName, athleteResponse.lastName, athleteResponse.id, participant.type) athletes.push(athlete) } })) } return athletes } export const getScoringPlayPat = async (scoringPlay: ScoringPlayInformationResponse) => { const isTwoPointAttempt = scoringPlay?.pointAfterAttempt?.id === 15 || scoringPlay?.pointAfterAttempt?.id === 16 || scoringPlay?.text?.toLowerCase()?.includes('two point') const twoPointAttemptSuccess = scoringPlay?.pointAfterAttempt?.value === 2 || (scoringPlay?.text?.toLowerCase()?.includes('two-point') && scoringPlay?.text?.toLowerCase()?.includes('attempt succeeds')) const participant = scoringPlay?.participants.find((participant: ParticipantResponse) => { return participant.type === SCORER_TYPE.PAT_SCORER }) const patScorerResponse = await getAtheleteInformation(participant?.athlete.$ref) let patScorer = undefined if (patScorerResponse && participant) { patScorer = new Athlete(patScorerResponse.firstName, patScorerResponse.lastName, patScorerResponse.id, participant.type) } return new PointAfterAttempt(twoPointAttemptSuccess, isTwoPointAttempt, patScorer) } export const getGameInformation = async (gameId: number) => { const scoringPlayIds = await getGameScoringPlayIds(gameId) const scoringPlayInformationResponse = await getScoringPlayInformation(gameId, scoringPlayIds) let scoringPlays: ScoringPlayInformation[] = [] if (scoringPlayInformationResponse) { scoringPlays = await Promise.all(scoringPlayInformationResponse.map(async (scoringPlay) => { const scoringPlayAthletes = await getScoringPlayAthletes(scoringPlay) const pointAfterAttempt = await getScoringPlayPat(scoringPlay) return new ScoringPlayInformation(scoringPlay.id, scoringPlayAthletes, pointAfterAttempt, scoringPlay.shortText, scoringPlay.text, new Date(scoringPlay.wallclock)) })) } return new Game(gameId, scoringPlays) }Again, the code is not perfect. There should be enums for readability, cleaner logic, and improvements around error handling. Additionally all of the methods could be encopassed within a class, instead of mixing OOP paradigms with functional.
Postgres Database
For persistence, I'm using Postgres and Typeorm. It was easy enough to create a couple of entities that would keep track of all of the necessary information. Since I'm paying to host the database myself, I've kept the models quite slim, relying only on ESPN internal identifiers to uniquely identify plays, players, and other things.
@Entity() export class OctopusCount { constructor(id: number, count: number) { this.id = id this.count = count } @Column({primary: true}) id: number @Column({type: 'int'}) count: number }
@Entity() export class ScoringPlay { constructor(id: number) { this.id = id } @Column({primary: true, type: 'bigint'}) id: number }
@Entity() export class PlayerOctopusCount { constructor(id: number, octopusCount: number = 0) { this.id = id this.octopusCount = octopusCount } @Column({primary: true, type: 'bigint'}) public id: number @Column({type: 'int', default: 0}) public octopusCount: number }
Leaderboard and Historical Data
In order to differentiate my bot from other "competitors", I decided to include statistics in the form of a leaderboard, and an all time player octopus count. The entities which aid me in this are PlayerOctopusCount and OctopusCount. Each time a player gets an octopus, these two tables are updated, and therefore allows me to gather relevant statistics when going to post to Twitter. Additionally, the ScoringPlay entity is vital. This helps to deduplicate already processed scoring plays, so no octopus or missed octopus is posted twice.Docker
The entire project is containerized, running in Docker for ease of deployment and system independence. Both a local Postgres and application instance can be spun up with the docker compose file.
services: app: build: context: . dockerfile: Dockerfile tty: true profiles: [app, all] environment: X_API_KEY: ${X_API_KEY} X_API_KEY_SECRET: ${X_API_KEY_SECRET} X_BEARER_TOKEN: ${X_BEARER_TOKEN} X_ACCESS_TOKEN: ${X_ACCESS_TOKEN} X_ACCESS_TOKEN_SECRET: ${X_ACCESS_TOKEN_SECRET} X_CLIENT_ID: ${X_CLIENT_ID} X_CLIENT_SECRET: ${X_CLIENT_SECRET} PG_USER: ${PG_USER} PG_PASS: ${PG_PASS} PG_DB: ${PG_DB} PG_HOST: ${PG_HOST} PG_PORT: ${PG_PORT} STARTING_OCTOPUS_COUNT: ${STARTING_OCTOPUS_COUNT} PORT: ${PORT} RECOVERY_MODE: ${RECOVERY_MODE} RECOVERY_START_DATE: ${RECOVERY_START_DATE} RECOVERY_END_DATE: ${RECOVERY_END_DATE} db: image: postgres:15 profiles: [pg, all] restart: always environment: POSTGRES_USER: ${PG_USER} POSTGRES_PASSWORD: ${PG_PASS} POSTGRES_DB: ${PG_DB} ports: - 5433:5432 volumes: - postgres_data:/var/lib/postgresql/data volumes: postgres_data:Additionally, the image is built and deployed to Docker Hub, to make the deployment process easy.
Render Deployment
Render offers a hosted Postgres solution, which is extremely easy to get up and running. Additionally, the bot is deployed via a Render background worker, by simply pointing to the container repository living on Docker Hub. last piece of the puzzle is to fill out the necessary environment variables to connect to the database, access Twitter, and set up the application. The total cost of deployment will most likely not exceed $15 dollars a month.Github Action
In order to build and push the docker image automatically, I have set up a simple github action that performs this on merges to the main branchname: Docker Image CI on: push: branches: - main jobs: build-and-push: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push Docker image uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile push: true tags: jackandrewgitter/nfl_octobot:latestThis as well requires additional secrets, particularly the docker hub username and password to allow the CLI to push the image on my behalf.
Donations
Donations are all performed via buy me a cofee. In order to keep track of all donations, I've created another entity@Entity('donations') export class Donation { constructor( id: number, money: number, donatorName: string, unixTimestamp: number, donatorId: number ) { this.id = id this.money = money this.donatorName = donatorName this.timestamp = new Date(unixTimestamp * 1000) this.donatorId = donatorId } @Column({primary: true, type: 'bigint'}) id: number @Column({type: 'float'}) money: number @Column({name: 'donator_name'}) donatorName: string @Column() timestamp: Date @Column() donatorId: number }When a donation occurs, a webhook from buy me a cofee calls an endpoint in my application which creates a corresponding donation record in the database.
app.post('/hook', async (req, res) => { if (!datasource.isInitialized) { await datasource.initialize() } const donationRepository = datasource.getRepository(Donation) const body: BuyMeACoffeeWebhook = req.body const valid = isSignatureValid(req) if (!valid) { res.status(403).send(`Invalid Signature`) return } console.log(`Donation Received! ${JSON.stringify(body)}`) const money = body?.data?.amount const name = body?.data.supporter_name const currency = body?.data?.currency const unixTimestamp = body?.data?.created_at const usdMoney = await convertToUSD(money, currency) const donation = new Donation(body?.data?.id, usdMoney, name, unixTimestamp) await donationRepository.save(donation) console.log(`Donation saved to database`) res.send(`Saved Donation`) })In order to verify that the request is sent by the intended source, buy me a coffee attaches a signed HTTP header in the request. The isSignatureValid method utilizes a secret value provided by buy me a coffee and set as an environment variable which verifies the signature against the request body. If all is aligned, then the donation amount gets normalized to USD, and the record gets saved to the database. Every last day of the month, a cron job runs which aggregates donation data and posts to Twitter
cron.schedule('0 0 28-31 * *', async () => { const today = new Date(); const tomorrow = new Date(today); tomorrow.setDate(today.getDate() + 1); if (tomorrow.getMonth() !== today.getMonth()) { console.log('📅 Running monthly donation summary...'); const highestAllTime = await getHighestAllTimeDonator(datasource); const highestMonthly = await getHighestMonthlyDonator(datasource); const totalMonthlyDonations = await getMonthlyDonationCount(datasource) console.log(`highest all time: ${JSON.stringify(highestAllTime)}`) console.log(`highest monthly: ${JSON.stringify(highestMonthly)}`) console.log(`total monthly: ${JSON.stringify(totalMonthlyDonations)}`) await tweetDonations( twitterClient, highestAllTime?.donatorName, highestAllTime?.total, highestMonthly?.donatorName, highestMonthly?.total, totalMonthlyDonations?.total ) } });Repo Link