DEV Community

Cover image for How to Scrape Park4Night Camper Van and RV Spots with Python
ReventaData
ReventaData

Posted on Edited on Originally published at apify.com

How to Scrape Park4Night Camper Van and RV Spots with Python

By the end of this tutorial, you will have a short Python script that turns a point on the map into structured rows of camper van and RV spots from Park4Night: place type, coordinates, address, listed services, rating, review count, photos and a ready Google Maps link.

Park4Night is a crowdsourced map of motorhome, camper van and RV parking and camping spots. In this guide, we use Python and the Apify Actor Park4Night Scraper (reventadata/park4night-scraper). You give it a latitude, a longitude and a radius; it returns one row per place, filtered by type, services and rating before anything is saved. The sample row shown below is a sample from a capture dated 2026-10-12, parsed with the Actor's own code.

⏱️ Time estimate: one sitting: install one package, set one token, run a few 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 get one row per Park4Night place. Each row carries the place id and url, a clean title, the place type and its short typeCode, latitude and longitude, distanceKm from your search center, the address split into street, zipcode, city and country, the services and activities codes, rating, reviewCount, photoCount, photo URLs in images, a googleMapsUrl and createdAt.

Note: Park4Night Scraper is unofficial. Per its README, rows carry no phone, email or reviewer fields, and user comments are not collected. Place descriptions are copied as written.

Example use case: Suppose you are planning a trip and want overnight parking or free motorhome areas within 40 km of Paris that have public toilets and drinking water. You would set the center to 48.8566, 2.3522, radiusKm: 40, two placeTypes and two requiredServices, and every saved row would be one of those two types and list both services.


Step 1: Access Park4Night Scraper

Go to the Park4Night 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 center

The search is a circle: latitude and longitude set the center and radiusKm the radius, from 1 to 200 km. These three plus maxItems are required. Let's start with the Actor's own prefill: central Paris, 20 km, 10 places.

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: a center point, a radius in km and a row cap
run_input = {
    "latitude": 48.8566,
    "longitude": 2.3522,
    "radiusKm": 20,
    "language": "en",
    "maxItems": 10,
}
Enter fullscreen mode Exit fullscreen mode

The Actor makes one search request per run, around that one center, then filters the places that come back. language takes en, fr, es, de, it or nl; per the input schema, it sets the language of place descriptions and types.

💡 Tip: Coordinates are WGS84 decimal degrees (48.8566, not 48°51′). The Actor checks that latitude sits within -90 to 90 and longitude within -180 to 180 before it searches.


Step 3: Add filters (optional)

Three filters and a row cap run inside the Actor after the search returns, and a place that fails one is never saved:

Parameter Type Default What it does
placeTypes array [] Keeps places whose type label or code matches, such as "Parking lot day/night", "Camping", "Free motorhome area", or the codes P and C. Case and accents are ignored. Empty means all types.
requiredServices array [] Keeps places that list every code you give, such as wc_public (public toilets), point_eau (drinking water), eau_noire (black water dump), electricite, douche (showers) or wifi.
minRating number 0 Drops places rated below it. 0 turns the filter off.
maxItems integer 100 Stops after this many places are kept, 1 to 5,000.

Here is the trip example from above as an input:

# A stricter search: two place types, two services, a rating floor
run_input = {
    "latitude": 48.8566,
    "longitude": 2.3522,
    "radiusKm": 40,
    "placeTypes": ["Parking lot day/night", "Free motorhome area"],
    "requiredServices": ["wc_public", "point_eau"],
    "minRating": 3.5,
    "maxItems": 50,
}
Enter fullscreen mode Exit fullscreen mode

The filters run in order (type, rating, services), and the run log counts each rejection reason.


Step 4: Run the Actor

With the input ready, start the run with client.actor("reventadata/park4night-scraper").call(run_input=run_input). It starts the Actor on Apify and waits until the run finishes:

# 3. Start the Actor run and wait for it to finish
print("Starting Park4Night search...")
run = client.actor("reventadata/park4night-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 places
places = client.dataset(run.default_dataset_id).list_items().items
print(f"Retrieved {len(places)} places.\n")
Enter fullscreen mode Exit fullscreen mode

Behind the scenes:

  • The Actor sends one search request for your circle. It uses no proxy by default (proxyConfiguration defaults to useApifyProxy: false).
  • It maps each place to a row, applies your filters and saves the rows to the run's dataset.
  • A run that saves no places ends FAILED if the search returned none or could not be read; if places loaded normally but your filters dropped them all, the run ends SUCCEEDED with an empty dataset and a status message saying so.

Step 5: View and download results

Loop over places to print what matters for a trip:

for place in places:
    print(f"{place['title']} [{place['type']}]")
    print(f"  Rating: {place['rating']} from {place['reviewCount']} review(s)")
    if place.get("distanceKm") is not None:
        print(f"  Distance: {place['distanceKm']} km")
    print(f"  Services: {', '.join(place['services']) or 'none listed'}")
    print(f"  Map: {place['googleMapsUrl']}")
    print("-" * 50)
Enter fullscreen mode Exit fullscreen mode

Here is one row, a sample from a capture dated 2026-10-12 of a single place record (place 593887, captured in French), parsed with the Actor's own code:

{
  "id": "593887",
  "name": "",
  "title": "(75001) Paris - 21 Avenue Victoria",
  "type": "Parking lot day/night",
  "typeCode": "P",
  "latitude": 48.858131,
  "longitude": 2.346107,
  "distanceKm": null,
  "street": "21 Avenue Victoria",
  "zipcode": "75001",
  "city": "Paris",
  "country": "France",
  "description": "Au milieu de la ville.\r\nA 10 minutes du Louvre. \r\nIdéal pour les petits camping-cars. \r\nCafés et restaurants à 20 m.",
  "services": [
    "wc_public",
    "boulangerie"
  ],
  "activities": [],
  "rating": 0.0,
  "reviewCount": 0,
  "photoCount": 0,
  "images": [
    "https://cdn3.park4night.com/lieu/1842101_1842200/1842175_gd.jpg"
  ],
  "url": "https://park4night.com/fr/place/593887",
  "googleMapsUrl": "https://www.google.com/maps/search/?api=1&query=48.858131,2.346107",
  "createdAt": "2025-05-30"
}
Enter fullscreen mode Exit fullscreen mode

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


Understanding your results

The fields you will use most:

  • title: the place's clean title; in the sample, postcode, town and street. The site's own title carries flag-icon HTML; the Actor keeps the plain version.
  • type and typeCode: the Park4Night category and its code, such as Parking lot day/night / P.
  • latitude, longitude, googleMapsUrl: coordinates, plus a Google Maps link the Actor builds from them.
  • distanceKm: kilometres from your search center, rounded to metres. The capture above is a single place record whose distance was empty, so it is null there.
  • services, activities: codes, most of them French words, such as wc_public, point_eau and boulangerie.
  • rating, reviewCount: an unrated place reads 0.0 with 0 reviews, as in the sample.
  • photoCount, images: the site's photo counter and the photo URLs. In the capture, photoCount was 0 while images held one URL, so read both.
  • description: copied as written, line breaks included.

Pro tips

  1. Use minRating knowing what 0.0 means. Unrated places read 0.0, so any minRating above 0 drops every place nobody has rated yet. Leave it at 0 unless you also want unrated places gone.
  2. Filter by code when you are unsure of the label. placeTypes matches the label or the code, so "P" and "Parking lot day/night" keep the same places.
  3. Several areas mean several runs. One run searches one circle. For a route, start one run per stop and merge the datasets.
  4. Start small. Keep maxItems: 10 while you check the fields, then raise it.
  5. Read the rejection tally before loosening. The final log line counts each reason (place_type_mismatch, rating_below_threshold, missing_service_<code>), so you can see which filter removed the most places and relax that one first.
  6. Keep the map link in exports. googleMapsUrl opens the spot's coordinates in Google Maps, so a CSV export stays one click from a map.

Troubleshooting

  • Zero places: a run that saves no places ends FAILED if the search returned none or could not be read; if places loaded normally but your filters dropped them all, the run ends SUCCEEDED with an empty dataset and a status message listing the rejections.
  • Run refused before it starts: radiusKm outside 1-200 or maxItems outside 1-5,000 breaks the input schema, and the platform refuses the input before the run starts.
  • Run fails at start with "unsupported": language must be one of the six codes; the Actor checks it first and stops.
  • Reading the log: the summary line reads Sweep complete: N place(s) emitted, rejections: {...}. Read it from Python with 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 repeat a search, 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.

Links:

Where are you planning to park next, and what would you filter for? Drop a comment! 💬

Top comments (0)