DEV Community

Cover image for How to Scrape EV Charging Stations and Live Plug Status with Python
ReventaData
ReventaData

Posted on Originally published at apify.com

How to Scrape EV Charging Stations and Live Plug Status with Python

By the end of this tutorial, you will have a short Python script that turns a rectangle on the map into one row per EV charging station: network, coordinates, top power in kW, each connector group, and how many connectors are available, charging or out of order.

The data comes from A Better Routeplanner (ABRP), an EV route planner whose web app loads chargers for the visible map area from its backend API. In this guide, we use Python and the Apify Actor ABRP EV Charging Scraper (reventadata/abrp-scraper), an unofficial tool not affiliated with ABRP or Iternio Planning AB. You will set a bounding box, run the Actor and filter by power and connector. The sample row below is a sample from a capture dated 2026-10-12, parsed with the Actor's own parser.

⏱️ Time estimate: one sitting: install one package, set one token, run three short Python blocks.


Prerequisites

To follow this tutorial, you will need:

  1. Python 3.11+ (apify-client 3.3.0 requires it).
  2. The Apify API client for Python.
  3. An Apify account and its API token, which sits on the API & Integrations page in Apify Console.

Install the client:

pip install "apify-client~=3.0"
Enter fullscreen mode Exit fullscreen mode

The code reads the token from an environment variable:

export APIFY_API_TOKEN="your_apify_token_here"
Enter fullscreen mode Exit fullscreen mode

What you'll achieve

You will turn a map rectangle into one row per charging station. Each row carries the station id, networkName, latitude and longitude, an operational status, maxPowerKw, connector totals (totalConnectors, availableConnectors, chargingConnectors, outOfOrderConnectors), a connectors list with one entry per group, and a sourceUrl that opens the station on ABRP.

Note: ABRP reports power in watts; the Actor converts it to kW, rounded to one decimal.

Example use case: Suppose you want CCS chargers of at least 150 kW in one city. You would set the four box edges around the city, minPowerKw: 150 and connectors: ["CCS"], and every saved station would have at least one CCS group and a top power of 150 kW or more.


Step 1: Access ABRP EV Charging Scraper

Go to the ABRP EV Charging Scraper page on Apify Store. The README lists every input and output field. In this tutorial we drive the Actor from Python, so everything after this step happens in code.


Step 2: Configure the bounding box

The main input is the box: four numbers in WGS84 decimal degrees, the format GPS and most map tools use.

  • bboxWest and bboxEast: the left and right longitudes (-180 to 180).
  • bboxSouth and bboxNorth: the bottom and top latitudes (-90 to 90).

All four are required. maxItems (default 100) caps the stations saved.

Here is the setup. It uses the input schema's prefill box, which contains the centre of Madrid (40.4169, -3.7033), and saves at most 10 stations:

import os
from apify_client import ApifyClient

# 1. Initialize ApifyClient with the token from the environment
client = ApifyClient(os.environ["APIFY_API_TOKEN"])

# 2. Define the area: the input schema's prefill box, at most 10 stations
run_input = {
    "bboxWest": -3.75,
    "bboxSouth": 40.40,
    "bboxEast": -3.65,
    "bboxNorth": 40.45,
    "maxItems": 10,
}
Enter fullscreen mode Exit fullscreen mode

💡 Tip: west must be smaller than east and south smaller than north, or the run stops with an error at its start. A box that crosses the 180th meridian therefore has to be split into two runs.

No proxy setting is needed: the proxy is off by default, and in recon on 2026-10-12 direct HTTPS calls reached ABRP's API host with no proxy, browser or challenge.


Step 3: Add optional filters

Two filters run on each station after the Actor reads it. Stations they drop never reach your dataset:

Parameter Type What it keeps
minPowerKw integer Stations whose fastest connector reaches N kW. 0 (the default) turns it off.
connectors array Stations with at least one of the listed standards: CCS, TYPE2, CHADEMO. Case does not matter; Type 2 with a space does not match.
apiKey secret string Not a filter: your own Iternio API key. When it is left empty, the Actor uses the key ABRP's web client sends.

Three combinations that work as input:

  • Motorway-speed CCS: minPowerKw: 150, connectors: ["CCS"].
  • CHAdeMO drivers: connectors: ["CHADEMO"], no power floor.
  • Everything in a box: no filters, maxItems: 100.

⚠️ A coordinate outside its range, or a missing box edge, is refused before the run starts.


Step 4: Run the Actor

Now start the run with client.actor("reventadata/abrp-scraper").call(run_input=run_input). The call starts the Actor on Apify and waits for it to finish before returning the run:

# 3. Start the Actor run and wait for it to finish
print("Starting ABRP charger extraction...")
run = client.actor("reventadata/abrp-scraper").call(run_input=run_input)
if run is None or run.status != "SUCCEEDED":
    status = run.status if run else "unknown"
    raise SystemExit(f"Run did not succeed (status: {status}).")

# 4. Fetch the saved stations
items = client.dataset(run.default_dataset_id).list_items().items
print(f"Retrieved {len(items)} stations.\n")
Enter fullscreen mode Exit fullscreen mode

Behind the scenes:

  • The Actor asks ABRP's bounding-box search for charger IDs inside your box, up to four times maxItems (at least 100).
  • It fetches the full station records in batches of 50, maps each to a row and applies your filters.
  • It stops once maxItems stations are saved or the IDs run out.

Step 5: View and download results

Loop over the rows to print the parts you need:

for item in items:
    print(f"{item.get('networkName') or 'unknown network'} ({item['id']})")
    print(f"  Max power: {item['maxPowerKw']} kW")
    print(f"  Free now: {item['availableConnectors']} of {item['totalConnectors']}")
    for c in item["connectors"]:
        print(f"    {c['standard']} {c['powerKw']} kW: {c['available']} of {c['count']} free")
    print(f"  Map: {item['sourceUrl']}")
    print("-" * 40)
Enter fullscreen mode Exit fullscreen mode

Here is one row, a sample from a capture dated 2026-10-12 (one station record of the Eviny network, not from the Madrid box above) parsed with the Actor's own parser:

{
  "id": "1986998632",
  "name": "Eviny",
  "networkId": 12498,
  "networkName": "Eviny",
  "latitude": 55.717689514160156,
  "longitude": 13.165938377380371,
  "status": "OPEN",
  "hasDynamicStatus": true,
  "maxPowerKw": 350.0,
  "totalConnectors": 23,
  "availableConnectors": 18,
  "chargingConnectors": 2,
  "outOfOrderConnectors": 3,
  "connectors": [
    {
      "standard": "CCS",
      "powerKw": 350.0,
      "count": 10,
      "available": 7,
      "charging": 2,
      "outOfOrder": 1
    },
    {
      "standard": "CCS",
      "powerKw": 200.0,
      "count": 6,
      "available": 4,
      "charging": 0,
      "outOfOrder": 2
    },
    {
      "standard": "CHADEMO",
      "powerKw": 63.0,
      "count": 6,
      "available": 6,
      "charging": 0,
      "outOfOrder": 0
    },
    {
      "standard": "TYPE2",
      "powerKw": 22.0,
      "count": 1,
      "available": 1,
      "charging": 0,
      "outOfOrder": 0
    }
  ],
  "sourceUrl": "https://abetterrouteplanner.com/?charger_id=1986998632"
}
Enter fullscreen mode Exit fullscreen mode

Apify datasets export as JSON, CSV, XML, Excel, HTML table, RSS or JSONL.


Understanding your results

The fields that matter most:

  • maxPowerKw: the highest power of any connector group. In the sample it is 350.0, from the first CCS group.
  • connectors: one entry per group, with standard, powerKw, count and the status split. The sample has four groups: CCS at 350 and at 200 kW, CHAdeMO at 63 kW and Type 2 at 22 kW.
  • availableConnectors, chargingConnectors, outOfOrderConnectors: totals across groups, as ABRP reported them when the record was fetched. In the sample, 18 of 23 were available, 2 charging and 3 out of order.
  • outOfOrder counts both statuses ABRP uses for a broken plug, OUTOFORDER and INOPERATIVE: the sample's 200 kW group had one of each, so it shows 2.
  • totalConnectors sums every group's count, so a status the Actor does not bucket still counts there.
  • hasDynamicStatus: whether live connector status is available for the station.
  • name currently holds the network name, the same value as networkName.

Pro tips

  1. Draw a small box first. The prefill box covers a slice of one city. Start there, check the fields, then widen it.
  2. Raise maxItems when you filter. The Actor requests four times maxItems candidate IDs (at least 100) and filters after fetching, so a strict filter can leave fewer than maxItems stations even when the area has more.
  3. Know what minPowerKw tests. It compares the station's fastest connector. A 350 kW station passes minPowerKw: 150 and still lists its 22 kW group in connectors; filter the list yourself if you need only the fast plugs.
  4. Use the standard names as written. CCS, TYPE2 and CHADEMO are the values in the sample; lowercase works, extra spaces do not.
  5. Split big areas. Several smaller boxes, run as separate runs or scheduled tasks, keep each run inside its maxItems.
  6. Automate from code. The same run_input works with .start() when you do not want to wait, and with a webhook that loads each finished dataset.

Troubleshooting

  • Run FAILED with zero rows: the Actor fails a run that saves nothing when it cannot confirm the box really was empty: the search returned no charger IDs, or a batch of station details came back empty. A box where stations loaded normally but every one failed your filters ends SUCCEEDED with an empty dataset and a status message listing the rejections.
  • Run stops at its start: bboxWest was not smaller than bboxEast, or bboxSouth not smaller than bboxNorth.
  • Input refused before the run starts: a box edge is missing or out of range.
  • Fewer stations than expected: read the log. Discovered N charger ID(s) across bounding box. gives the candidates; Sweep complete: N stations emitted, rejections: ... counts the filter drops. From Python: client.log(run.id).get().

For help, use the Actor's Store page.


Pricing and next steps

For current pricing and details, see the Store page.

To watch one area, save the input as a task and add an Apify schedule, which runs an Actor or task on a cron expression (0 8 * * * is every day at 8 AM). To react when a run finishes, add a webhook on ACTOR.RUN.SUCCEEDED; its payload carries the run object, including defaultDatasetId.

Which city or corridor are you planning to map chargers for? Drop a comment! 💬

Top comments (0)