# Sports Betting Odds Scraper & Line Move Monitor - NFL, MLB, NBA (`neverempty/sports-betting-odds-scraper`) Actor

Turn monitoring on and every later run returns only the prices that have actually moved, so you are never charged for the same line twice. Every ESPN bookmaker line as JSON: spread, total and moneyline, plus the opening line and how far it has moved since. NFL, MLB, NBA and NHL.

- **URL**: https://apify.com/neverempty/sports-betting-odds-scraper.md
- **Developed by:** [NeverEmpty](https://apify.com/neverempty) (community)
- **Categories:** Sports, News, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $5.84 / 1,000 bookmaker price returneds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Sports Betting Odds Scraper & Line Move Monitor — NFL, MLB, NBA, NHL

**Turn monitoring on and every later run returns only the prices that have actually moved, so you are never charged for the same line twice.**

Every bookmaker line ESPN publishes, as one flat row: spread, total and moneyline for both sides — **plus the opening line and how far it has moved since**.

> **Unofficial.** This Actor is not affiliated with or endorsed by ESPN or any bookmaker. It reads ESPN's public feeds — no key, no login, no HTML parsing. It reports published prices; it does not give betting advice.

### What it does

- 📈 **The opening line and the move, not just today's number.** ESPN's feed carries both `open` and `current`, so every row says where the line started and how far it travelled. Move is computed in **decimal odds**, because American odds (−110 next to +110) are not a continuous scale you can subtract.
- 🔁 **Monitoring follows movement.** The remembered key is the game *plus the bookmaker plus the actual numbers*, so a price comes back only when it really changed.
- 🔀 **Favourite flips.** Every row says whether the side favoured now is the side favoured at open.
- 🧾 **Four leagues:** NFL, MLB, NBA, NHL.

### 🔴 The trap this Actor avoids

**ESPN's scoreboard embeds odds for some leagues and not others.** On 2026-09-06 all **16** NFL games carried an embedded line, while **0** of the **15** MLB games did — yet every MLB game checked had a line in ESPN's core odds endpoint.

A scraper that reads only the embedded odds silently returns **nothing for MLB** and looks like it worked. This Actor always reads the core endpoint, and uses the scoreboard only for the fixture.

It also separates **"no odds yet" from "no odds"**: on 2026-09-06 the NBA board (next game 2026-10-03) and the NHL board (2026-09-19) carried **no lines at all**, because bookmakers had not posted them. That comes back as its own row saying so, and is not charged.

### What you get in every row

| Field | What it is |
|---|---|
| `gameId`, `gameName`, `gameShortName`, `gameDate` | ESPN's game and when it starts |
| `gameStatus`, `gameState`, `isCompleted` | `Scheduled` / `pre`, `in`, `post` |
| `venue`, `venueCity` | Where it is played |
| `homeTeam`, `awayTeam` (+ `…Id`, `…Abbreviation`) | Both sides |
| `provider`, `providerId` | The bookmaker ESPN is quoting |
| `details` | ESPN's own summary, e.g. `SEA -3.5` |
| `spread`, `overUnder` | The current line |
| `homeMoneyLine`, `awayMoneyLine` | American odds, e.g. `-185` / `154` |
| `homeSpreadOdds`, `awaySpreadOdds`, `overOdds`, `underOdds` | The price attached to each side |
| `openSpread`, `openOverUnder` | Where the line opened |
| `openHomeMoneyLineDecimal`, `currentHomeMoneyLineDecimal` (+ away) | Decimal odds then and now |
| `spreadMove`, `totalMove`, `homeMoneyLineMove`, `awayMoneyLineMove` | How far each has moved — `null`, never `0`, when the opening value is not published |
| `homeIsFavorite`, `homeWasFavoriteAtOpen`, `favoriteFlipped` | Who is favoured, and whether that changed |
| `oddsKey` | Changes when any number changes — this is what monitoring compares |

A real row from the fixtures: `NE @ SEA`, DraftKings, spread `-3.5`, total `44.5`, home moneyline `-185`, away `154`; the home moneyline opened at decimal `1.52` and sits at `1.54`, a move of `+0.02`; the favourite did not flip.

#### Two things worth knowing about the data

- **`spread` does not mean the same thing in every league.** In the NFL it is a point spread (`-3.5`); in MLB it is the run line, which is `±1.5` on almost every game. Compare within a league, not across.
- **Moves are `null` when the opening value is not published**, not `0`. A filter on movement never drops a row whose opening line is unknown — unknown is not the same as unmoved.

### What you can do with it

**📉 Track line movement.** Monitoring mode on a schedule returns a row only when a number actually changes.

**🔀 Catch favourite flips.** `onlyFavoriteFlips` returns the games where the market changed its mind.

**🎯 Watch one team.** `teams: ["SEA"]` plus monitoring gives a movement log for one club.

**📊 Build a closing-line dataset.** Finished games keep their closing numbers, so a daily run builds an open-to-close history.

### Configure the run

Today's NFL and MLB boards:

```json
{
  "leagues": ["nfl", "mlb"],
  "maxPrices": 200
}
```

Follow movement on a schedule, paying only for moves:

```json
{
  "leagues": ["nfl"],
  "monitoringMode": true,
  "maxGames": 20
}
```

Only games where the line moved at least a point:

```json
{
  "leagues": ["nfl", "mlb"],
  "onlyMoved": true,
  "minSpreadMove": 1
}
```

### Input reference

Every field is optional; the defaults read today's NFL and MLB boards.

| Field | Type | Default | What it does |
|---|---|---|---|
| `leagues` | array | `["nfl", "mlb"]` | Which leagues to read. Odds appear closer to the game: on 2026-09-06, all 4 NFL games checked carried a line and all 4 MLB games did, while NBA (next game 2026-10-03) and NHL… |
| `date` | string | `(empty)` | Which day's board to read. Empty = ESPN's current board for each league. |
| `maxGames` | integer | `40` | Each game costs one extra request for its odds, so this caps how much of the board is read. Reading is free; only returned prices are charged. |
| `maxPrices` | integer | `200` | How many rows to return. One row is one game at one bookmaker. You are charged for the rows you actually receive. |
| `monitoringMode` | boolean | `false` | Off = return every price for the games you picked. On = remember the exact numbers already returned and, on later runs, return a price again only when it has actually changed.… |
| `resetMonitoringState` | boolean | `false` | Clear the remembered prices before this run, so everything counts as new again. |
| `providers` | array | `[]` | Keep a price only if the bookmaker name contains one of these (case-insensitive, partial). ESPN's own feed carried DraftKings on every priced game checked on 2026-09-06. |
| `teams` | array | `[]` | Keep a game only if either side's name or abbreviation contains one of these (case-insensitive, partial). Example: Seahawks, SEA. |
| `gameStates` | array | `[]` | ESPN's own state for the game: pre (not started), in (in progress), post (finished). Empty = all. Note that finished games still carry the closing line. |
| `onlyMoved` | boolean | `false` | Keep a price only if at least one of spread, total or moneyline differs from where it opened. Prices whose opening line is not published are kept, because unknown is not the… |
| `minSpreadMove` | integer | `0` | Keep a price only if the spread has moved at least this far from its open, in points, in either direction. 0 = no limit. Prices with no opening spread are kept. |
| `onlyFavoriteFlips` | boolean | `false` | Keep only games where the side favoured now is not the side favoured when the line opened. |
| `requireMoneyLine` | boolean | `false` | Drop rows where neither side has a moneyline price. |
| `keywords` | array | `[]` | Keep a price only if one (Any) or all (All) of these appear in the game name, either team, the bookmaker or the line summary. |
| `keywordMatch` | string | `any` | Any = at least one keyword. All = every keyword. |
| `excludeKeywords` | array | `[]` | Drop a price if any of these appear in the same fields. |
| `useProxy` | boolean | `false` | Off by default: ESPN's public feeds answered 200 from a plain Apify datacentre address when this Actor was built (2026-09-06). If they start answering 403, 429 or 503, the… |

### Pricing

Pay per result: you are charged for the price rows you actually receive. Rows that only explain why there is nothing (`no-results`, `no-odds-yet`, `no-such-league`, `unreadable`, `no-filter-match`, `no-line-move`) are never charged. Reading a board is free — only returned prices cost anything.

### What is deliberately not included

ESPN's odds carry **affiliate-tagged links into the bookmakers' own betting slips**. Those are not returned, and a test fails if one ever appears in the output.

### Every number in this README is checked

The fixtures in `test/fixtures/` are the real feeds this Actor was built from (2026-09-06). The test suite re-counts every figure quoted above — 16, 15, 1, 7, 4, 0, 44.5, -3.5, -185, 154, 1.52, 1.54, 0.02 — so if ESPN changes its feed, the tests fail rather than the data going quietly wrong.

# Actor input Schema

## `leagues` (type: `array`):

Which leagues to read. Odds appear closer to the game: on 2026-09-06, all 4 NFL games checked carried a line and all 4 MLB games did, while NBA (next game 2026-10-03) and NHL (2026-09-19) carried none at all - which is 'not posted yet', not 'no odds exist'.

## `date` (type: `string`):

Which day's board to read. Empty = ESPN's current board for each league.

## `maxGames` (type: `integer`):

Each game costs one extra request for its odds, so this caps how much of the board is read. Reading is free; only returned prices are charged.

## `maxPrices` (type: `integer`):

How many rows to return. One row is one game at one bookmaker. You are charged for the rows you actually receive.

## `monitoringMode` (type: `boolean`):

Off = return every price for the games you picked. On = remember the exact numbers already returned and, on later runs, return a price again only when it has actually changed. Run it on a schedule to follow line movement and pay only for the moves.

## `resetMonitoringState` (type: `boolean`):

Clear the remembered prices before this run, so everything counts as new again.

## `providers` (type: `array`):

Keep a price only if the bookmaker name contains one of these (case-insensitive, partial). ESPN's own feed carried DraftKings on every priced game checked on 2026-09-06.

## `teams` (type: `array`):

Keep a game only if either side's name or abbreviation contains one of these (case-insensitive, partial). Example: Seahawks, SEA.

## `gameStates` (type: `array`):

ESPN's own state for the game: pre (not started), in (in progress), post (finished). Empty = all. Note that finished games still carry the closing line.

## `onlyMoved` (type: `boolean`):

Keep a price only if at least one of spread, total or moneyline differs from where it opened. Prices whose opening line is not published are kept, because unknown is not the same as unmoved.

## `minSpreadMove` (type: `integer`):

Keep a price only if the spread has moved at least this far from its open, in points, in either direction. 0 = no limit. Prices with no opening spread are kept.

## `onlyFavoriteFlips` (type: `boolean`):

Keep only games where the side favoured now is not the side favoured when the line opened.

## `requireMoneyLine` (type: `boolean`):

Drop rows where neither side has a moneyline price.

## `keywords` (type: `array`):

Keep a price only if one (Any) or all (All) of these appear in the game name, either team, the bookmaker or the line summary.

## `keywordMatch` (type: `string`):

Any = at least one keyword. All = every keyword.

## `excludeKeywords` (type: `array`):

Drop a price if any of these appear in the same fields.

## `useProxy` (type: `boolean`):

Off by default: ESPN's public feeds answered 200 from a plain Apify datacentre address when this Actor was built (2026-09-06). If they start answering 403, 429 or 503, the Actor switches to a proxy session on its own and tries again.

## Actor input object example

```json
{
  "leagues": [
    "nfl",
    "mlb"
  ],
  "maxGames": 40,
  "maxPrices": 200,
  "monitoringMode": false,
  "resetMonitoringState": false,
  "providers": [],
  "teams": [],
  "gameStates": [],
  "onlyMoved": false,
  "minSpreadMove": 0,
  "onlyFavoriteFlips": false,
  "requireMoneyLine": false,
  "keywords": [],
  "keywordMatch": "any",
  "excludeKeywords": [],
  "useProxy": false
}
```

# Actor output Schema

## `results` (type: `string`):

One row per game per bookmaker: the league, the game with its ESPN id, date, status and venue, both teams with ids and abbreviations; the bookmaker; the current spread, over/under and moneyline for both sides, plus the odds attached to the spread and to over and under; the opening spread, opening total and opening moneylines as decimal odds; and how far each of those has moved since it opened, computed in decimal odds rather than American, which is not a continuous scale. Each row also says whether the home side is the favourite, whether it was at open, and whether the favourite flipped. Games with no odds yet, leagues with no games, filters that matched nothing and feeds that could not be read come back as their own rows and are not charged. Bookmaker affiliate links are deliberately not included.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "leagues": [
        "nfl",
        "mlb"
    ],
    "date": ""
};

// Run the Actor and wait for it to finish
const run = await client.actor("neverempty/sports-betting-odds-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "leagues": [
        "nfl",
        "mlb",
    ],
    "date": "",
}

# Run the Actor and wait for it to finish
run = client.actor("neverempty/sports-betting-odds-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "leagues": [
    "nfl",
    "mlb"
  ],
  "date": ""
}' |
apify call neverempty/sports-betting-odds-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,neverempty/sports-betting-odds-scraper"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/4fTqdYBgw1617WnIB/builds/I1J757dNkn0mlga77/openapi.json
