DEV Community

Cover image for How to Scrape Electromaps EV Charging Stations with Python
ReventaData
ReventaData

Posted on Originally published at apify.com

How to Scrape Electromaps EV Charging Stations with Python

By the end of this tutorial, you will have a short Python script that turns a map rectangle into a list of EV charging stations from Electromaps: name, address, coordinates, connector mix, highest power in kW, community rating, opening hours and the price text the station publishes.

Electromaps is a charging-station map. It has no self-serve public API, so getting its stations into a spreadsheet by hand means clicking through the map one pin at a time.

In this guide, we use Python and the Apify Actor Electromaps EV Charging Scraper (reventadata/electromaps-scraper). You will draw a bounding box, add filters, run the Actor and read the rows. The sample row shown below is a sample from a capture dated 2026-09-22, parsed with the Actor's own code.

⏱️ 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 bounding box into one row per charging station. Each row carries the station id and url, name, address, city, postalCode, countryCode, latitude and longitude, the site type (locationType), rating and ratingCount, openTime, chargePrice, and a connectors list with each connector's type, status, power, voltage and amperage.

Note: statuses come back exactly as Electromaps reports them. Where a station's realtime flag is false, its statuses are crowdsourced, not live, and UNKNOWN stays UNKNOWN.

Example use case: Suppose you want every on-street or parking-lot charger of at least 50 kW inside a city. You would set the four box edges around the city, locationTypes: ["ON_STREET", "PARKING"] and minPowerKw: 50, and get one row per matching station with its connectors listed.


Step 1: Access Electromaps EV Charging Scraper

Go to the Electromaps 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 four required inputs are the edges of the search box, in WGS84 decimal degrees (the latitude and longitude numbers any map shows):

  • bboxWest and bboxEast: the minimum and maximum longitude, between -180 and 180.
  • bboxSouth and bboxNorth: the minimum and maximum latitude, between -90 and 90.

West must be smaller than east and south smaller than north; otherwise the run stops with an error at its start. A value outside the ranges above is refused before the run starts.

Here is the setup. It uses the Actor's prefill box, a small rectangle in Vilanova i la Geltrú, Spain, with on-street and parking sites, detail on, 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 search: one small box, two site types, at most 10 stations
run_input = {
    "bboxWest": 1.7227,
    "bboxSouth": 41.2135,
    "bboxEast": 1.724,
    "bboxNorth": 41.2145,
    "locationTypes": ["ON_STREET", "PARKING"],
    "maxItems": 10,
    "fetchDetails": True,
}
Enter fullscreen mode Exit fullscreen mode

💡 Tip: on a web map that displays coordinates, read the four numbers off two opposite corners of the area you want. Start with a small box; you can widen it once the rows look right.


Step 3: Add optional filters

The first four are sent to Electromaps with the box, so stations that do not match never come back; the last two are the Actor's own settings:

Parameter Type What it does
locationTypes array Site types to keep. Omit it for all 12 the Actor knows.
minPowerKw integer Minimum connector power in kW. 0 turns it off.
connectors array Connector codes such as IEC_62196_T2 or CHADEMO. Empty turns it off.
realtimeOnly boolean Only stations that report live data.
maxItems integer Cap on stations processed (default 100).
fetchDetails boolean Not a filter: one extra request per station for connectors, ratings and prices (default on).

The 12 site types the Actor accepts are ON_STREET, PARKING, AIRPORT, CAMPING, HOTEL, RESTAURANT, SHOP, WORKSHOP, FUEL_STATION, CAR_DEALER, MALL and TAXI. Lowercase is fine; the Actor upper-cases them.

Three combinations that work:

  • Fast chargers in a city: a city box, minPowerKw: 50.
  • Hotel chargers for a trip: a region box, locationTypes: ["HOTEL", "CAMPING"].
  • A quick pin map: any box, fetchDetails: false for id, name and coordinates only.

⚠️ A site type outside those 12 stops the run at its start, before any request is sent: Electromaps answers unknown types with HTTP 403.


Step 4: Run the Actor

Now start the run with client.actor("reventadata/electromaps-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 Electromaps extraction...")
run = client.actor("reventadata/electromaps-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 sends one request for the stations inside your box, with your filters, to the JSON that Electromaps' own map reads. No HTML is scraped, and no key or login is involved.
  • With fetchDetails on, it then requests each station's detail, up to maxItems stations, and turns it into one row.
  • A station whose detail fails is logged and skipped; the run carries on with the rest.

Step 5: View and download results

Loop over the rows to print the parts you need:

for item in items:
    print(item["name"], "|", item.get("city"))
    print(f"  Max power: {item.get('maxPowerKw')} kW, connectors: {item.get('connectorCount')}")
    print(f"  Status: {item.get('offlineStatus')} (realtime: {item.get('realtime')})")
    print(f"  Price: {item.get('chargePrice') or 'not published'}")
    for conn in item.get("connectors") or []:
        print(f"    {conn['type']} {conn['powerKw']} kW {conn['powerType']}: {conn['status']}")
    print("-" * 40)
Enter fullscreen mode Exit fullscreen mode

Here is one row, a sample from a capture dated 2026-09-22 (the station inside the prefill box) parsed with the Actor's own code. A saved row also carries scrapedAt, the run's timestamp:

{
    "id": 307432,
    "name": "Ribes Roges 11 Vilanova i la Geltrú",
    "url": "https://map.electromaps.com/en/p/307432",
    "address": "Passeig de Ribes Roges, 10, 08800, Vilanova i la Geltrú",
    "city": "Vilanova i la Geltrú",
    "postalCode": "08800",
    "countryCode": "ES",
    "latitude": 41.213871159682,
    "longitude": 1.723192892969,
    "locationType": "ON_STREET",
    "rating": 3.1481,
    "ratingCount": 27,
    "onlineStatus": "UNKNOWN",
    "offlineStatus": "WORKING",
    "realtime": false,
    "openTime": "24h",
    "chargePrice": "Gratuito",
    "parkingPrice": "",
    "priceKwhFrom": null,
    "instructions": "App EVCharge",
    "contact": "Etecnic",
    "reservable": false,
    "onlyTesla": false,
    "connectorCount": 2,
    "maxPowerKw": 22.0,
    "connectors": [
        {
            "type": "IEC_62196_T2",
            "status": "UNKNOWN",
            "powerKw": 22.0,
            "powerType": "AC_3_PHASE",
            "voltage": 400,
            "amperage": 32,
            "format": "SOCKET"
        },
        {
            "type": "IEC_62196_T2",
            "status": "WORKING",
            "powerKw": 22.0,
            "powerType": "AC_3_PHASE",
            "voltage": 400,
            "amperage": 32,
            "format": "SOCKET"
        }
    ]
}
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:

  • id and url: Electromaps' numeric station id and the station's page on its map (map.electromaps.com/en/p/<id>), handy as a join key between runs.
  • maxPowerKw and connectorCount: the highest connector power and the number of connectors. In the sample, two 22 kW connectors.
  • connectors: one object per connector. type is Electromaps' code (IEC_62196_T2 here), powerType is the current type (AC_3_PHASE here), plus voltage, amperage and format (SOCKET).
  • status, offlineStatus, onlineStatus, realtime: verbatim. The sample has realtime: false, so its statuses are crowdsourced; one connector reads WORKING and the other UNKNOWN, and the Actor guesses neither.
  • chargePrice and parkingPrice: text as the station publishes it. Gratuito is Spanish for free; the sample's parkingPrice is an empty string. priceKwhFrom is a number only when a per-kWh price is published.
  • rating and ratingCount: the community rating, 3.1481 from 27 ratings in the sample.
  • instructions and contact: access notes (App EVCharge) and the operator's name (Etecnic). Uploader usernames, avatars and ads are dropped.

Pro tips

  1. Size the box to maxItems. A Barcelona-area box in the 2026-09-22 capture held 1,955 stations, and maxItems defaults to 100. Raise the cap or split a city into several boxes.
  2. Filter on the server. locationTypes, minPowerKw, connectors and realtimeOnly are applied by Electromaps, so a narrow filter shrinks the list before any detail request.
  3. Skip details for a pin map. fetchDetails: false saves id, name and coordinates only, with no extra request per station.
  4. Treat statuses as reported, not live. Check realtime before you show a connector as available. For live-only data, set realtimeOnly: true.
  5. Start small. Keep maxItems: 10 until your downstream code handles the fields, then raise it.
  6. Connect it to the rest of your pipeline. A webhook on ACTOR.RUN.SUCCEEDED hands your endpoint the run object with its defaultDatasetId, so a script can load each new dataset as soon as a run ends. For bigger datasets, iterate_items() pages through the rows instead of loading them in one call.

Troubleshooting

  • Zero rows: a run that is blocked or cannot read a station's detail ends FAILED; an empty box or an over-strict filter ends SUCCEEDED with an empty dataset and a status message saying so. Widen the box or loosen minPowerKw.
  • Run stops at once: west is not smaller than east, south is not smaller than north, or a locationTypes value is not one of the 12.
  • Input refused before the run starts: a box edge outside -180 to 180 or -90 to 90, or a required edge missing.
  • Fewer rows than expected: read the summary line near the end of the log, Run completed. Pushed N rows. Rejected: {...}; it counts skipped stations by reason, such as a failed detail request. 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 refresh a box regularly, 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 area are you planning to map? Drop a comment! 💬

Top comments (0)