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:
- Python 3.11+ (
apify-client3.3.0 requires it). - The Apify API client for Python.
- 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" The code reads the token from an environment variable:
export APIFY_API_TOKEN="your_apify_token_here" 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, } 💡 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") 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
fetchDetailson, 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) 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." } ] } 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:
-
idandlocationId: 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 showedAVAILABLE. -
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 kWDOMESTIC_Fand a 7 kWIEC_62196_T2. -
connectors[].tariffDescription: the tariff text exactly as Freshmile publishes it, not split into numbers. All 8 captured connectors carried the same tariff, inEUR. -
isOpenandtwentyfourseven: flags as Freshmile reports them. In the 2026-09-22 captures, the same station readisOpen: truein the map search andfalsein its detail, so check both before relying on it.twentyfourseven: falsewith emptyregularHoursmeans no hours were published, not that the station is closed. -
scrapedAt: one timestamp per run, taken when the station search finishes.
Pro tips
- 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.
- Skip details for a quick map.
fetchDetails: falsereturns positions,isAvailableandevseCountwithout a detail request per station. - Use
maxItemsas 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. - Compare tariffs by text. Group rows by
tariffNameortariffDescriptionto see which stations share a price plan. - Leave the proxy off. The Actor calls the API directly by default; turn on
proxyConfigurationonly if the log shows refused requests. - Track availability over time. Schedule the same box and join runs on
id, Freshmile's stable station ref; each run'sscrapedAttells you when itsisAvailablevalues 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! 💬