DEV Community

Cover image for How to Scrape Freshmile EV Charging Stations and Tariffs with Python
ReventaData
ReventaData

Posted on Originally published at apify.com

How to Scrape Freshmile EV Charging Stations and Tariffs with Python

By the end of this tutorial, you will have a short Python script that turns a map box into structured rows of EV charging stations on the Freshmile network: name, address, coordinates, live availability, connector types, power in kW and each connector's tariff as Freshmile publishes it.

There is no HTML to parse. The Actor reads Freshmile's public JSON map API, which answered with no key, token or login when it was measured on 2026-09-22, and calls it directly, with no proxy by default.

In this guide, we use Python and the Apify Actor Freshmile Scraper (reventadata/freshmile-scraper). You will set a search box, run the Actor and read the rows. The sample row below is a sample from a capture dated 2026-09-22, 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 bounding box (four map edges in decimal degrees) into one row per charging station. Each row carries the station id (Freshmile's station ref) and locationId, name, address, city, postalCode, country, latitude and longitude, the isAvailable flag, evseCount (EVSEs, the charging points), connectorCount, maxPowerKw, and a connectors list with each connector's power, standard and tariff.

Note: The Actor reads station data only. Its rows carry no user fields and no map preview image link (the site's preview link embeds a third-party key, so it is left out).

Example use case: Suppose you want every Freshmile station around one Paris street with its connector mix and tariff text, to compare charging prices. You draw a tight box around the street, keep fetchDetails on, and each saved row carries its connectors with the tariff wording Freshmile shows.


Step 1: Access Freshmile Scraper

Go to the Freshmile 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 search box

The main input is the box: bboxWest, bboxSouth, bboxEast and bboxNorth, in WGS84 decimal degrees (the longitude and latitude any web map shows). All four are required. West must be smaller than east, and south smaller than north.

Two caps control the size of the run:

  • zoom: the zoom hint sent with the box (0 to 22, default 18).
  • maxItems: the most stations the run processes (default 100).

Here is the setup. It searches a small box in central Paris, the same box the Actor's input form is prefilled with, and processes 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 box (Paris, around Rue Pierre Charron) and the caps
run_input = {
    "bboxWest": 2.303,
    "bboxSouth": 48.869,
    "bboxEast": 2.3045,
    "bboxNorth": 48.8705,
    "zoom": 18,
    "maxItems": 10,
    "fetchDetails": True,
}
Enter fullscreen mode Exit fullscreen mode

💡 Tip: keep the box tight. Freshmile groups stations into clusters by box size and density, not by zoom alone: on 2026-09-22 a tight Paris box at zoom 15 or 18 returned single stations, while a wide box over the same area (2.0,48.5,2.7,49.0) at zoom 20 returned 11 clusters and no single station.


Step 3: Choose how much detail you need

fetchDetails (default true) decides what each row holds:

fetchDetails What the Actor does What you get
true Calls each station's detail endpoint Name, address, EVSEs, every connector with power, standard and tariff
false Keeps the search result only id, locationId, coordinates, isAvailable, isOpen, evseCount; name, address and connectors stay empty

Turn it off when you only need positions and availability: the run skips one detail request per station.

proxyConfiguration is optional and off by default. The input form says it plainly: the API answers direct calls; enable a proxy only if direct calls are refused.

⚠️ A box edge outside the valid range (longitude -180 to 180, latitude -90 to 90) or a missing edge is refused before the run starts. A box with west not smaller than east, or south not smaller than north, stops the run with an error at its start.


Step 4: Run the Actor

Now start the run with client.actor("reventadata/freshmile-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 Freshmile extraction...")
run = client.actor("reventadata/freshmile-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 your box to Freshmile's map search, which answers with single stations and clusters.
  • When clusters come back, it splits the box into four quarters and searches each, up to three levels deep, and stops splitting once it has enough stations.
  • With fetchDetails on, it requests each station's detail, turns it into a row and saves it.

Step 5: View and download results

Loop over the rows to print the parts you need:

for item in items:
    print(item["name"] or item["id"])
    print(f"  City: {item.get('city')}  Available: {item.get('isAvailable')}")
    print(f"  Max power: {item.get('maxPowerKw')} kW, connectors: {item.get('connectorCount')}")
    tariffs = {c["tariffDescription"] for c in item["connectors"] if c.get("tariffDescription")}
    for text in tariffs:
        print(f"  Tariff: {text.splitlines()[0]}")
    print("-" * 40)
Enter fullscreen mode Exit fullscreen mode

Here is one row, a sample from a capture dated 2026-09-22 (a station inside the box above), parsed with the Actor's own parser. connectors is cut to the first 2 of its 8 entries, and scrapedAt, the run's timestamp, is left out:

{
  "id": "FRV75PX08014",
  "locationId": 172076,
  "name": "Paris | Rue Pierre Charron  62",
  "address": "62 Rue Pierre Charron",
  "city": "Paris",
  "postalCode": "75008",
  "country": "FRA",
  "latitude": 48.869644,
  "longitude": 2.30362,
  "isAvailable": true,
  "isOpen": false,
  "twentyfourseven": false,
  "regularHours": [],
  "evseCount": 4,
  "connectorCount": 8,
  "maxPowerKw": 7.0,
  "connectors": [
    {
      "powerKw": 2.0,
      "standard": "DOMESTIC_F",
      "isAttached": true,
      "tariffName": "Interop sortante - Normal temps (20)",
      "tariffCurrency": "EUR",
      "tariffDescription": "€ 0.25 / started kWh + € 0.07 / min.\r\nThe pricing continues as long as the vehicle remains plugged in."
    },
    {
      "powerKw": 7.0,
      "standard": "IEC_62196_T2",
      "isAttached": false,
      "tariffName": "Interop sortante - Normal temps (20)",
      "tariffCurrency": "EUR",
      "tariffDescription": "€ 0.25 / started kWh + € 0.07 / min.\r\nThe pricing continues as long as the vehicle remains plugged in."
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

For that row, the loop prints the station name, City: Paris Available: True, Max power: 7.0 kW, connectors: 8 and one tariff line.

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


Understanding your results

The fields that matter most:

  • id and locationId: Freshmile's station ref, stable per station, and the numeric id its detail endpoint uses.
  • isAvailable: at least one charge point is available at read time. In the capture, all 4 of the station's charge points showed AVAILABLE.
  • evseCount, connectorCount, maxPowerKw: charge points, connectors across them, and the highest connector power. The captured station had 4 charge points with 2 connectors each: a 2 kW DOMESTIC_F and a 7 kW IEC_62196_T2.
  • connectors[].tariffDescription: the tariff text exactly as Freshmile publishes it, not split into numbers. All 8 captured connectors carried the same tariff, in EUR.
  • isOpen and twentyfourseven: flags as Freshmile reports them. In the 2026-09-22 captures, the same station read isOpen: true in the map search and false in its detail, so check both before relying on it. twentyfourseven: false with empty regularHours means no hours were published, not that the station is closed.
  • scrapedAt: one timestamp per run, taken when the station search finishes.

Pro tips

  1. Many tight boxes beat one wide box. Clusters are split at most three levels deep; stations still clustered after that are not returned. For a whole city, run several small boxes, one task each.
  2. Skip details for a quick map. fetchDetails: false returns positions, isAvailable and evseCount without a detail request per station.
  3. Use maxItems as a budget. It caps the stations processed, search hits and detail requests alike. Keep it at 10 while you test your code, then raise it.
  4. Compare tariffs by text. Group rows by tariffName or tariffDescription to see which stations share a price plan.
  5. Leave the proxy off. The Actor calls the API directly by default; turn on proxyConfiguration only if the log shows refused requests.
  6. Track availability over time. Schedule the same box and join runs on id, Freshmile's stable station ref; each run's scrapedAt tells you when its isAvailable values were read.

Troubleshooting

  • Zero rows: a run where the box search was refused or every station's detail request failed ends FAILED; a box that loads normally but holds no Freshmile station ends SUCCEEDED with an empty dataset and a status message saying so.
  • Run stops at once: check the box edges; west must be smaller than east, south smaller than north.
  • Fewer rows than expected: read the summary line near the end of the log, Run completed. Pushed N rows. Rejected: {...}; it counts stations skipped by reason, such as a failed detail request. From Python: client.log(run.id).get().
  • Wide box, few stations: split the box (Pro tip 1).

For help, use the Actor's Store page.


Pricing and next steps

For current pricing and details, see the Store page.

To refresh availability on a timetable, 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 charging areas are you planning to map? Drop a comment! 💬

Top comments (0)