DEV Community

Cover image for How to Scrape Leboncoin.fr Ads with Python and Leboncoin Scraper
ReventaData
ReventaData

Posted on Originally published at apify.com

How to Scrape Leboncoin.fr Ads with Python and Leboncoin Scraper

By the end of this tutorial you will have a short Python script that turns a leboncoin.fr search into structured rows: title, price in euros, city and postal code, seller name and type, the ad's attributes (condition, model, storage) and its publication date.

Leboncoin is a French classifieds site. Its search pages hold exactly what price trackers and resellers want, but a plain HTTP client does not get far: in a test on 2026-11-07, a plain client on a home connection was served 6 of 25 result pages before the site began refusing it, and a datacenter IP was served 0 of 25.

So we will use the Apify Actor Leboncoin Scraper (reventadata/leboncoin-scraper) from Python and let it deal with that.

⏱️ Time estimate: about 30 minutes.


Prerequisites

You need:

  1. Python 3.11 or newer (the apify-client 3.x package on PyPI declares >=3.11).
  2. An Apify account and API token. Copy the token from the API & Integrations page in Apify Console.
  3. The Apify client for Python:
pip install -U "apify-client~=3.0"
Enter fullscreen mode Exit fullscreen mode

The script 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 run one search ("iphone", phones category, newest first, 100 to 300 euros) and print each ad with its price, city, seller and condition. Along the way you will see how the rows are shaped, so you can swap in your own keyword.

Example use case: you watch second-hand phone prices in France and want fresh ads under a price ceiling, with the seller type next to each one. The same input works for any keyword and any leboncoin category number.

The rows come from the search result pages. The ad description and the seller's phone number are not in the Actor's output; hasPhone only says whether the ad has a number.


Step 1: Access Leboncoin Scraper

Open the Leboncoin Scraper page on Apify Store to read the input fields. This tutorial drives the Actor from Python, so the script below replaces clicking through a form.


Step 2: Configure your search

The main input is searchQueries: one search per entry. Everything else is optional:

  • category: a leboncoin category number, the category= value in a leboncoin.fr search URL. 17 is phones and connected devices. Leave it out to search every category.
  • sort: relevance (default) or newest. With newest, boosted (paid) ads can come first, ahead of newer unboosted ads, in the order the site returns them; isBoosted marks them. In the 2026-11-10 sample rows the three boosted ads came first and were published earlier than the unboosted ads that follow.
  • minPrice, maxPrice: price limits in euros.
  • maxItems: cap on rows across all queries.
  • maxPages: pages per query, 35 ads each.
import os
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_API_TOKEN"])

run_input = {
    "searchQueries": ["iphone"],
    "category": "17",
    "sort": "newest",
    "minPrice": 100,
    "maxPrice": 300,
    "maxItems": 20,
    "maxPages": 1,
}
Enter fullscreen mode Exit fullscreen mode

Tip: start with maxPages: 1. Every page is a separate request made one after another, and the input schema describes one page as taking about 25 seconds.


Step 3: Optional settings

When both minPrice and maxPrice are set, they go to leboncoin in the search URL and the Actor checks them again on every ad. A single limit is applied only after the fetch. In both cases an ad with no price is dropped once a limit is set.

maxPages defaults to 3 and cannot go above 100. maxItems defaults to 100.


Step 4: Run the Actor

Start the run from Python and wait for it to finish:

print("Starting Leboncoin extraction...")
run = client.actor("reventadata/leboncoin-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}).")

items = client.dataset(run.default_dataset_id).list_items().items
print(f"Retrieved {len(items)} ads.\n")
Enter fullscreen mode Exit fullscreen mode

call() blocks until the run ends, so the script waits for the Actor.


Step 5: View and download results

Print the ads:

for item in items:
    attrs = item["attributes"]
    price = "no price" if item["price"] is None else f'{item["price"]} EUR'
    seller = item["ownerName"] or "unknown seller"
    print(item["title"])
    print(f'  {price} | {item["city"]} ({item["zipcode"]}) | {item["ownerType"]}: {seller}')
    print(f'  condition: {attrs.get("condition")} | model: {attrs.get("phone_model")} | storage: {attrs.get("phone_memory")}')
    print(f'  {item["url"]}')
Enter fullscreen mode Exit fullscreen mode

Two sample rows from a run on 2026-11-10 (imageUrls cut to one entry and attributes to four keys; the Actor returns them whole):

{
  "listId": 3306570323,
  "url": "https://www.leboncoin.fr/ad/telephones_objets_connectes/3306570323",
  "title": "IPhone 11",
  "price": 230,
  "priceCents": 23000,
  "currency": "EUR",
  "categoryId": "17",
  "categoryName": "Téléphones & Objets connectés",
  "adType": "offer",
  "status": "active",
  "imageUrls": [
    "https://img.leboncoin.fr/api/v1/lbcpb1/images/37/e4/8c/37e48c57804e286f2e6d43476995333e8e3ca448.jpg?rule=ad-large"
  ],
  "imageCount": 6,
  "attributes": {
    "condition": "Très bon état",
    "phone_model": "iPhone 11",
    "phone_memory": "64 Go",
    "shipping_type": "face_to_face"
  },
  "city": "Béning-lès-Saint-Avold",
  "zipcode": "57800",
  "department": "Moselle",
  "region": "Lorraine",
  "latitude": 49.139,
  "longitude": 6.841,
  "ownerName": "Deborah545",
  "ownerType": "private",
  "ownerSiren": null,
  "hasPhone": false,
  "isBoosted": false,
  "firstPublishedAt": "2026-11-10 20:51:10",
  "expiresAt": null,
  "indexedAt": "2026-11-10 20:51:10",
  "matchedQuery": "iphone",
  "searchUrl": "https://www.leboncoin.fr/recherche?text=iphone&category=17&sort=time&order=desc&price=100-300&page=1"
}
Enter fullscreen mode Exit fullscreen mode
{
  "listId": 3178587975,
  "url": "https://www.leboncoin.fr/ad/telephones_objets_connectes/3178587975",
  "title": "IPhone 13 Pro Max & iPhone 12 Pro Max avec garantie",
  "price": 250,
  "priceCents": 25000,
  "currency": "EUR",
  "categoryId": "17",
  "categoryName": "Téléphones & Objets connectés",
  "adType": "offer",
  "status": "active",
  "imageUrls": [
    "https://img.leboncoin.fr/api/v1/lbcpb1/images/e8/e5/69/e8e56900bea92208828d8aa9be006e1db5b3f1ae.jpg?rule=ad-large"
  ],
  "imageCount": 2,
  "attributes": {
    "condition": "Bon état",
    "phone_model": "iPhone 13 Pro Max",
    "phone_memory": "128 Go",
    "shippable": "false"
  },
  "city": "Marseille",
  "zipcode": "13001",
  "department": "Bouches-du-Rhône",
  "region": "Provence-Alpes-Côte d'Azur",
  "latitude": 43.293,
  "longitude": 5.371,
  "ownerName": "DBC MARSEILLE",
  "ownerType": "pro",
  "ownerSiren": "993294982",
  "hasPhone": true,
  "isBoosted": true,
  "firstPublishedAt": "2026-10-26 19:24:32",
  "expiresAt": "2026-12-25 19:24:32",
  "indexedAt": "2026-11-10 19:24:33",
  "matchedQuery": "iphone",
  "searchUrl": "https://www.leboncoin.fr/recherche?text=iphone&category=17&sort=time&order=desc&price=100-300&page=1"
}
Enter fullscreen mode Exit fullscreen mode

items is a plain list of dictionaries, so you can load it into a spreadsheet, a database or pandas from here.


Understanding your results

  • listId, url, title: the ad's id, link and title.
  • price, priceCents, currency: price in euros and in cents; price is null when the ad has none. currency is always EUR.
  • categoryId, categoryName: the category number and its French name.
  • attributes: the ad's attributes by key, with the site's own values: French labels such as Très bon état, but also raw codes such as face_to_face or false. Phone ads usually carry condition, phone_model and phone_memory; a car ad carries mileage, fuel and so on.
  • city, zipcode, department, region, latitude, longitude: location.
  • ownerName, ownerType, ownerSiren: the public seller name, private or pro, and the SIREN of a professional seller.
  • hasPhone, isBoosted: whether a number is on the ad, and whether the ad is promoted.
  • firstPublishedAt, expiresAt, indexedAt: dates as the site gives them; expiresAt is null when the site gives none.
  • matchedQuery, searchUrl: the search that found the ad.

Pro tips

  1. The Actor does not filter by text. It returns what the site returns. In the page 25 capture dated 2026-11-07, one of 35 ads was a car (a Volkswagen Polo at 8990 EUR in the Voitures category), and phone-category rows included a cable at 4 EUR and a repair service at 55 EUR. Set category: "17", as above, and filter further in Python.
  2. Promoted ads repeat across pages. Each ad is returned once per run. In the page 1 capture dated 2026-11-07 all 35 ads had isBoosted: true; in the page 25 capture none did.
  3. Private sellers only:
private_phones = [
    item for item in items
    if item["categoryId"] == "17" and item["ownerType"] == "private" and item["price"] is not None
]
private_phones.sort(key=lambda item: item["price"])
for item in private_phones[:5]:
    print(item["price"], item["title"], item["city"])
Enter fullscreen mode Exit fullscreen mode
  1. Several keywords in one run: pass more than one entry in searchQueries; maxItems caps the total.
  2. Know the ceiling. A search shows at most 100 pages of 35 ads, so 3,500 ads. The page 1 capture reports 377,398 results for "iphone"; narrow with category and price limits.

Troubleshooting

  • Run FAILED on input: an empty searchQueries list, a category that is not 1 to 3 digits, or minPrice above maxPrice fails the run with a status message naming the problem.
  • Run SUCCEEDED with no rows: every requested page loaded normally, but nothing matched (or price limits dropped every ad). The status message says so; loosen the keyword or price limits.
  • Run FAILED with zero rows and a message about unreadable pages: the site blocked a request, a page failed the shape check, or the run clock stopped the run before any row was stored.
  • Fewer rows than maxItems: the search ran out of pages, maxPages was reached, or a price limit dropped ads.

The log's summary lines are Skipped N: reason (when ads were skipped) and Pushed N row(s).; read it with client.log(run.id).get().


Next steps

For current pricing and details, see the Store page.

To repeat a search automatically, save it as a task and add an Apify schedule, which starts Actor or task runs on a cron expression. For event-driven pipelines, an Apify webhook that fires when a run status reaches SUCCEEDED sends the run object, which carries defaultDatasetId, to your endpoint.

What are you planning to scrape on leboncoin? Drop a comment! 💬

Top comments (0)