No description
Find a file
Leonard Kramer e001cc5919 vendor deps, fix Docker build for private fuelapi module
go.mod was bumped to go 1.26.5 with an external private-git dependency
(forgejo.ubik-shakespeare.com/.../fuelapi.git) in a8dd05b, but the
Dockerfile's golang:1.24-alpine builder both predates that Go version
and has no git binary or credentials to reach a private forgejo host,
so any fresh build failed. The running v1 container was built before
that commit and never hit it.

Vendor the two dependencies instead of teaching the build how to fetch
them: no network/git access needed during `go build`, and it works
with the Docker version already installed here (no buildx/BuildKit
SSH-agent forwarding required).
2026-08-15 11:55:56 +01:00
cmd/fuel-history Using external fuelapi 2026-08-15 10:43:33 +01:00
docs/openapi fetch and store PFS station info (location, amenities, opening times) 2026-08-03 16:26:44 +01:00
pkg v2: stop flattening station info, persist fuelapi.StationInfo as-fetched 2026-08-15 11:50:40 +01:00
vendor vendor deps, fix Docker build for private fuelapi module 2026-08-15 11:55:56 +01:00
.dockerignore initial scraper for GOV.UK Fuel Finder PFS prices 2026-05-04 18:34:18 +01:00
.gitignore v2: stop flattening station info, persist fuelapi.StationInfo as-fetched 2026-08-15 11:50:40 +01:00
config.example.yaml gzip yesterday's NDJSON, add log file, README 2026-05-04 18:51:12 +01:00
docker-compose.yml v2: stop flattening station info, persist fuelapi.StationInfo as-fetched 2026-08-15 11:50:40 +01:00
Dockerfile vendor deps, fix Docker build for private fuelapi module 2026-08-15 11:55:56 +01:00
go.mod Using external fuelapi 2026-08-15 10:43:33 +01:00
go.sum Using external fuelapi 2026-08-15 10:43:33 +01:00
README.md fetch and store PFS station info (location, amenities, opening times) 2026-08-03 16:26:44 +01:00
V2_PLAN.md v2: stop flattening station info, persist fuelapi.StationInfo as-fetched 2026-08-15 11:50:40 +01:00

fuel_history

A small Go service that periodically scrapes the GOV.UK Fuel Finder PFS fuel-prices API and writes the results as flattened NDJSON to a daily-rotated file for later analysis.

  • One row per (station × fuel type) per scrape — denormalised for time-series analysis.
  • Daily UTC file rotation (prices-YYYY-MM-DD.ndjson); previous days are gzipped automatically (~10× compression) once the next day's scrape begins.
  • Sequential paginated fetch with OAuth2 client-credentials, in-memory token cache + 401/403 retry, configurable scrape interval.
  • Distroless Docker image (~9 MB) and a ready-to-go docker-compose.yml.

Prerequisites

  • For deployment: Docker + Docker Compose v2.
  • For local development: Go 1.24+.
  • A client_id / client_secret pair from the Fuel Finder developer portal.

Configuration

Copy the example and fill in your credentials:

cp config.example.yaml config.yaml
$EDITOR config.yaml

config.yaml is gitignored. Field reference:

api:
  base_url: https://www.fuel-finder.service.gov.uk   # production server
  client_id: YOUR_CLIENT_ID
  client_secret: YOUR_CLIENT_SECRET

scrape:
  interval: 30m            # Go duration, e.g. 10m, 30m, 1h
  request_timeout: 60s     # per-HTTP-request timeout

storage:
  dir: ./data              # NDJSON output directory

logging:
  file: ./data/fuel-history.log   # optional; INFO+ tee'd here. Empty disables.

Deploying with Docker Compose

cp config.example.yaml config.yaml      # then edit credentials
mkdir -p data
docker compose up -d --build

If id -u on your server is not 1001, edit the user: line in docker-compose.yml accordingly so files in ./data are owned by your host user. Also ensure the data/ directory itself is writable by that uid: sudo chown 1001:1001 data (or whichever uid you set).

Operations

docker compose logs -f                         # stream container logs
docker compose ps                              # service status
docker compose run --rm fuel-history --once    # one-shot scrape
docker compose down                            # stop and remove

Container stdout logs are capped at 5 × 10 MB by Docker's json-file driver (see logging: block in docker-compose.yml). The application's own fuel-history.log file is unbounded — it's low-noise (a few lines per scrape) so a year of operation is a few MB.

Running locally (without Docker)

go build -o fuel-history ./cmd/fuel-history
./fuel-history --config config.yaml --once     # one scrape, then exit
./fuel-history --config config.yaml --debug    # continuous, verbose stdout
./fuel-history --help

CLI flags:

  • --config PATH — path to YAML config (default config.yaml).
  • --once — perform a single scrape and exit (useful for cron / smoke tests).
  • --debug — DEBUG-level logging on stdout. The log file always uses INFO+.

Output format

NDJSON, one JSON object per line:

{
  "scrape_time": "2026-05-04T17:20:24.230Z",
  "node_id": "0028acef…",
  "trading_name": "Alex Fuel Station",
  "public_phone_number": "+448003234040",
  "fuel_type": "E10",
  "price": 132.9,
  "price_last_updated": "2026-02-17T16:03:04.938Z",
  "price_change_effective_timestamp": "2026-02-17T16:00:00.000Z"
}

Station metadata (location, amenities, opening times) is fetched once per UTC day — not on every tick, since it rarely changes — and written the same way:

{
  "scrape_time": "2026-05-04T17:20:24.230Z",
  "node_id": "0028acef…",
  "trading_name": "Alex Fuel Station",
  "brand_name": "Alex",
  "public_phone_number": "",
  "temporary_closure": false,
  "is_motorway_service_station": false,
  "is_supermarket_service_station": false,
  "address_line_1": "1 Example Road",
  "city": "Springfield",
  "county": "Countyshire",
  "country": "England",
  "postcode": "SP1 1AA",
  "latitude": 51.5074,
  "longitude": -0.1278,
  "amenities": ["car_wash", "customer_toilets"],
  "opening_times": { "usual_days": { "monday": { "open": "06:00:00", "close": "22:00:00", "is_24_hours": false }, "...": "..." } },
  "fuel_types": ["E10", "E5", "B7_STANDARD"]
}

Files in data/:

  • prices-YYYY-MM-DD.ndjson — current day, append-only.
  • stations-YYYY-MM-DD.ndjson — station metadata snapshot, written once per day.
  • prices-YYYY-MM-DD.ndjson.gz / stations-YYYY-MM-DD.ndjson.gz — previous days, gzip-compressed in place.
  • fuel-history.log — application log (if logging.file is set).

Analysing the data

# Pretty-print one record
head -1 data/prices-2026-05-04.ndjson | jq .

# Count distinct stations and fuel types
jq -r .node_id  data/prices-*.ndjson | sort -u | wc -l
jq -r .fuel_type data/prices-*.ndjson | sort -u

# Join prices to station location (latest snapshot)
duckdb -c "SELECT p.trading_name, p.fuel_type, p.price, s.postcode, s.latitude, s.longitude
           FROM read_json_auto('data/prices-*.ndjson*', format='newline_delimited') p
           JOIN read_json_auto('data/stations-*.ndjson*', format='newline_delimited') s
           USING (node_id)"

# Cheapest E10 right now (top 5)
jq -c 'select(.fuel_type=="E10")' data/prices-*.ndjson \
  | jq -s 'sort_by(.price)[:5]'

# Read compressed and uncompressed together
zcat -f data/prices-*.ndjson*

# DuckDB SQL across all days at once (handles .gz automatically)
duckdb -c "SELECT fuel_type, COUNT(*), AVG(price)
           FROM read_json_auto('data/prices-*.ndjson*', format='newline_delimited')
           GROUP BY fuel_type"

Project layout

cmd/fuel-history/main.go    flags, signal handling, slog wiring
pkg/config/                 YAML loader + defaults + validation
pkg/fuelapi/                token cache + paginated fetch + 401-retry
pkg/store/                  daily NDJSON sink + gzip-on-rollover
pkg/logging/                slog multi-handler (per-sink levels)
pkg/scraper/                ticker loop + flatten + compress orchestration
config.example.yaml
docker-compose.yml
Dockerfile
docs/openapi/             vendored OpenAPI spec + how to re-fetch it

Tests

go test ./...
go vet ./...

Notes / limitations

  • API reference: see docs/openapi/ for a vendored copy of the OpenAPI spec and instructions on re-fetching it (the docs site is a JS-rendered SPA, so it can't be scraped directly).
  • We call the full-snapshot variants of both fuel-prices and pfs (getAllPFSFuelPrices / getPFSInformation, paginated by batch), not the effective-start-timestamp incremental variants — see docs/openapi/README.md for the endpoint list.
  • API quirks worth knowing about:
    • Token endpoint takes a JSON body ({client_id, client_secret}) — not standard OAuth2 form-encoded, despite what the spec implies.
    • The PFS endpoint returns a bare JSON array (the documented {data: [...]} wrapper is absent).
    • End of pagination is signalled by HTTP 404 with a "Requested batch X is not available" body — not an empty array.
    • Periodic 504 Gateway Timeout responses with a "Maintenance" HTML page are common; the scraper logs the error and waits for the next tick.
  • No retention policy yet — old .gz files accumulate forever. Add a find data -name '*.ndjson.gz' -mtime +90 -delete cron job if you want a sliding window.
  • Rate limit: 429 responses say "try again in 5 minutes". A scrape that hits one is aborted; the next ticker fire retries the whole thing. No sophisticated back-off yet.