# UK Fuel Prices Scraper (`scrapyx/uk-fuel-prices-scraper`) Actor

Today's petrol and diesel prices at ~3,000 UK forecourts, from the retailers' own CMA open-data feeds: station, brand, postcode, coordinates and pence per litre for E10, E5, B7 and super diesel. Stale feeds are excluded by age -- one publishes prices nineteen months old.

- **URL**: https://apify.com/scrapyx/uk-fuel-prices-scraper.md
- **Developed by:** [Ibnu Adzim](https://apify.com/scrapyx) (community)
- **Categories:** Other
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.05 / 1,000 results

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?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## 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.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## UK Fuel Prices Scraper

Today's petrol and diesel prices at ~3,000 UK forecourts, straight from the
retailers' own open-data feeds published under the CMA road-fuel price scheme:
station address and postcode, coordinates, brand, and **pence per litre** for
E10, E5, B7 diesel and super diesel.

HTTP only. One request per retailer, no pagination, no key — these files are
published for exactly this use.

### What it is for

- **Cheapest fuel near a postcode**, refreshed on a schedule.
- **Regional price monitoring** — by postcode area, brand or retailer.
- **Price history** — run it daily and keep the rows.

### Input

| field | what it does |
| --- | --- |
| `retailers` | Any of the ten reachable feeds. Empty = all. |
| `maxFeedAgeDays` | Exclude feeds older than this. **Default 7.** See below. |
| `includeStaleFeeds` | Keep stale feeds' stations, labelled. |
| `fuelTypes`, `postcodeAreas`, `brands` | Filters. |
| `excludeSuspectRows` | Drop placeholder ids and non-UK coordinates. Default on. |

### Three things about this data worth knowing before you trust a run

#### 1. Some feeds are months old, and their prices look like prices

Each feed carries **one** `last_updated` timestamp. On the day this Actor was
built:

| feed | last updated | age |
| --- | --- | --- |
| Esso, JET, Moto, MFG, SGN | today / yesterday | fresh |
| Shell | 13/05/2026 | 4 months |
| Tesco | 29/04/2026 | 4.5 months |
| Rontec | 01/05/2026 | 4.5 months |
| Applegreen | 26/02/2025 | **19 months** |

Applegreen's E10 at 138.8p is not obviously wrong — it is a price petrol really
had, in early 2025. Merged with today's ~170p it drags every average down and
sends every "cheapest station" query to a forecourt whose price nobody can
verify.

So feeds older than `maxFeedAgeDays` are **excluded by default**, the summary
row for each names its age and says `feed_stale`, every kept station carries
`feedAgeDays`, and `includeStaleFeeds` brings them back labelled if you want
them.

#### 2. Five of the fifteen scheme feeds are not here — and the run says so

| feed | why |
| --- | --- |
| **Asda** (790 stations) | its own `robots.txt` disallows the feed path. The scheme's intent does not override the host's robots in this Actor. |
| BP | HTTP 403 bot challenge |
| Sainsbury's | host does not resolve |
| Ascona | broken TLS certificate |
| Karan | times out |

Every run emits a `feed_not_fetched` row for each, so the coverage gap is data
rather than a footnote. Asking for one of them by name is refused with the
reason.

#### 3. The feeds share a schema and disagree about everything inside it

- `last_updated` is **DD/MM/YYYY** — `01/05/2026` is May, and a month-first
  parser puts Rontec eight months in the past or fails on `13/05/2026`.
- **Morrisons' feed holds one station, with string coordinates, in Gibraltar.**
  Coordinates outside the UK are flagged (`coordinatesInUk`) and excluded by
  default; MFG's feed had five such rows too.
- **Brand casing**: `SHELL` and `Shell`, `ESSO` and `Esso`, `TEXACO` and
  `Texaco` — a group-by on the raw field splits each. `brand` is normalised;
  `brandRaw` is kept.
- **Placeholder site ids** — `7zzzzzzzzzzz`, the geohash of a missing
  coordinate, appears in more than one feed. Flagged and excluded.
- **JET publishes `175.89999999999998`.** Rounded to the scheme's one decimal.
- **Shell serves JSON as `application/octet-stream`** at a URL ending in
  `.html`. The body decides, not the header.
- **All feeds are in pence today.** The scheme has seen retailers publish
  pounds (`1.699`); a value below 10 is treated as pounds, converted, and the
  row says so in `pricesConvertedFromPounds`.

### Output

- **`STATION`** — `retailer`, `brand` + `brandRaw`, `address`, `postcode`,
  `postcodeArea`, `latitude`/`longitude`, `e10`/`e5`/`b7`/`sdv` (pence per
  litre), `fuelsSold`, `feedLastUpdated`, `feedAgeDays`, `feedIsStale`,
  `coordinatesInUk`, `siteIdIsPlaceholder`.
- **`FEED_SUMMARY`** — one per retailer: `stationsInFeed`, `stationsReturned`,
  `feedAgeDays`, `feedIsStale`, `placeholderSiteIds`, `stationsOutsideUk`,
  `fuelCoverage`, `stoppedReason`.
- **`ERROR`** — `feed_not_fetched` for each absent scheme feed, and anything
  that went wrong.

### Known limits

- `last_updated` is **per feed, not per station**. A fresh feed can still carry
  a station whose price has not moved in months (MFG, updated today, lists E10
  at 131.9p among a field of ~170p). That is beyond what this Actor can prove
  either way and is not guessed at with an outlier heuristic.
- Asda, BP, Sainsbury's, Ascona and Karan are absent — see above.
- UK only, pence per litre.

# Actor input Schema

## `retailers` (type: `array`):

Which CMA scheme feeds to read. Leave empty for all ten reachable ones. Asda is part of the scheme but its robots.txt disallows the feed, so it is never fetched; BP, Sainsbury's, Ascona and Karan are unreachable (403 / dead host / broken TLS / timeout). Every run reports those five as feed\_not\_fetched rows so the coverage gap is visible.

## `maxFeedAgeDays` (type: `integer`):

Each feed carries ONE last\_updated timestamp. On the day this was built, four of ten were stale - Tesco, Rontec and Shell by ~4 months and Applegreen by 19 months - and their prices (E10 at 138.8p) look perfectly plausible next to today's ~170p. Stations from feeds older than this are excluded and the summary names each excluded feed with its age. Set to 0 to accept only feeds updated today.

## `includeStaleFeeds` (type: `boolean`):

Keep stations from stale feeds, each labelled with feedAgeDays and feedIsStale, instead of dropping them.

## `fuelTypes` (type: `array`):

Keep only stations selling at least one of these. E10 = petrol, E5 = super unleaded, B7 = diesel, SDV = super diesel.

## `postcodeAreas` (type: `array`):

Outward-code letters - SW, M, EH, BS. Keep only stations in these areas.

## `brands` (type: `array`):

Shell, Esso, BP, Tesco, Texaco, JET... Matched against the normalised brand - the feeds spell Shell three ways.

## `excludeSuspectRows` (type: `boolean`):

Drop stations whose site\_id is a placeholder geohash (7zzzzzzzzzzz) or whose coordinates fall outside the UK - Morrisons' feed, for instance, holds a single station located in Gibraltar. They are counted in the summary either way.

## `maxItems` (type: `integer`):

Overall cap. The ten reachable feeds hold about 3,000 stations between them.

## `maxConcurrency` (type: `integer`):

Feeds fetched in parallel. Each is one request.

## `proxyConfiguration` (type: `object`):

Optional. Every feed answered 200 from a plain IP; these are published for exactly this kind of use.

## Actor input object example

```json
{
  "maxFeedAgeDays": 7,
  "includeStaleFeeds": false,
  "excludeSuspectRows": true,
  "maxItems": 5000,
  "maxConcurrency": 4,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `items` (type: `string`):

One row per scraped record. See the dataset's default view for field definitions.

# 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 = {};

// Run the Actor and wait for it to finish
const run = await client.actor("scrapyx/uk-fuel-prices-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 = {}

# Run the Actor and wait for it to finish
run = client.actor("scrapyx/uk-fuel-prices-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 '{}' |
apify call scrapyx/uk-fuel-prices-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,scrapyx/uk-fuel-prices-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/yu2YjVfs4hKSIZfTh/builds/5VpBrft1HfVsuUzrr/openapi.json
