DEV Community

Cover image for How to Scrape Kleinanzeigen Listings with Python
ReventaData
ReventaData

Posted on Originally published at apify.com

How to Scrape Kleinanzeigen Listings with Python

By the end of this tutorial, you will have a Python script that pulls used-phone ads from Kleinanzeigen.de into structured JSON: title, asking price in EUR, whether the price is negotiable, town and postcode, posting time, shipping and buy-now flags, and the ad URL.

Kleinanzeigen.de is a German classifieds site where people sell second-hand phones, bikes, furniture and cars. Copying ads by hand into a spreadsheet gets old quickly, and each search shows only 25 ads per page.

In this guide, we use Python and the Apify Actor Kleinanzeigen Scraper (reventadata/kleinanzeigen-scraper). Our benchmark run (configured with searchQueries: ["iphone 13"], sellers: "private", shippingOnly: true, maxItems: 60 and maxPages: 4) finished in 9.3 seconds and returned 60 ads.

⏱️ Time estimate: About 30 minutes.


Prerequisites

To follow this tutorial, you will need:

  1. Python 3.11+ installed locally.
  2. The Apify API client for Python (pip install apify-client).
  3. An Apify account and its API token.

Copy your API token from API & Integrations in Apify Console, then install the client:

pip install apify-client
Enter fullscreen mode Exit fullscreen mode

What you'll achieve

You will run the Actor from Python and get back one JSON record per Kleinanzeigen ad. The records come straight from the search result cards, so no ad page is opened unless you ask for it.

Note on data boundaries: the default run reads the public search result cards only. The seller fields (sellerName, sellerKind, sellerSince and a few others) stay null unless you turn on fetchDetails.

Example use case: Suppose you want to compare asking prices for a used iPhone 13 that private sellers will ship. One input, searchQueries: ["iphone 13"] with sellers: "private" and shippingOnly: true, returns exactly that list.


Step 1: Access Kleinanzeigen Scraper

Open the Kleinanzeigen Scraper page on Apify Store. The page lists the input fields and a sample of the output. The Try for free button links to Apify Console. In this guide we skip the Console form and start the Actor from Python.


Step 2: Configure search parameters

The main input is searchQueries, a list of search terms. Each term is searched across every category of kleinanzeigen.de, Germany-wide, unless you add a category or location ID. Here is the input we used for the benchmark run:

from apify_client import ApifyClient

client = ApifyClient("<YOUR_APIFY_TOKEN>")

run_input = {
    "searchQueries": ["iphone 13"],
    "sellers": "private",
    "shippingOnly": True,
    "maxItems": 60,
    "maxPages": 4,
}

run = client.actor("reventadata/kleinanzeigen-scraper").call(run_input=run_input)

for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item["title"], item["price"], item["currency"], item["location"], item["url"])
Enter fullscreen mode Exit fullscreen mode

This is the exact input behind the numbers in this article. maxPages: 4 caps the walk at four result pages of 25 ads each, and maxItems: 60 stops the run once 60 rows are saved.


Step 3: Configure optional settings and filters

The Actor has more switches than the example uses:

Parameter Type Default What it does
searchQueries array none One search per term. Optional if you give a categoryId.
categoryId integer none Numeric category from the site's own URLs (173 = Smartphones and phones).
locationId integer none Numeric place from the site's URLs (3331 = Berlin). Needs a search term or category ID.
offerType string offers offers, wanted or all. Wanted ads are the cards marked Gesuch ("I am looking for").
sellers string any any, private or proShops.
minPrice, maxPrice integer none Inclusive EUR bounds, applied after fetching.
shippingOnly boolean false Keeps only cards marked Versand möglich.
excludePromoted boolean false Drops the undated cards shown ahead of the dated results.
requireKeywordMatch boolean true Drops ads where a search word is missing from both title and card text.
fetchDetails boolean false Opens each kept ad for description, attributes and seller fields.
maxItems integer 100 Hard cap on rows across all search terms.
maxPages integer 50 Pages per search term, at most 50 (1,250 ads).

Three details are worth knowing before you rely on a filter:

  • sellers: "private" means "no PRO badge". The Actor judges seller kind from the search card, so a commercial seller without a PRO shop badge is listed as private. Turn on fetchDetails and read sellerKind for the site's own label.
  • Price bounds drop negotiable-only ads. Ads with no numeric price (just "VB") are dropped whenever you set minPrice or maxPrice. Free ads count as 0.
  • The site mixes in wanted ads. The default offerType: "offers" already removes them.

Step 4: Run the Actor

Replace the token placeholder, save the script and run it. .call() starts the Actor in the Apify cloud and waits until it finishes. The Actor then fetches the result pages one by one, filters each card against your input, and saves the kept ads to the run's dataset. Keep the script running until it prints; our benchmark run finished in 9.3 seconds.

Each line the script prints looks like the sample record in the next step, cut down to five fields.


Step 5: View and download results

The loop in Step 2 iterates the dataset item by item. In Apify Console the same dataset can be exported as JSON, CSV or Excel from the run's storage. Here is the first record of our benchmark run, verbatim:

{
  "id": "3549899772",
  "title": "IPhone 13 Pro Max",
  "url": "https://www.kleinanzeigen.de/s-anzeige/iphone-13-pro-max/3549899772-173-702",
  "price": 290,
  "currency": "EUR",
  "priceNegotiable": true,
  "isFree": false,
  "previousPrice": null,
  "postcode": "24143",
  "location": "Gaarden",
  "region": null,
  "postedAt": "2026-10-23T20:59:00+02:00",
  "postedDate": "2026-10-23",
  "isPromoted": false,
  "isWanted": false,
  "shipping": true,
  "buyNow": false,
  "sellerWarranty": false,
  "isProShop": false,
  "shopName": null,
  "shopUrl": null,
  "imageUrl": "https://img.kleinanzeigen.de/api/v1/prod-ads/images/4b/4b5daab1-628b-45a6-a28b-258f983bb9b4?rule=$_2.AUTO",
  "imageCount": 4,
  "snippet": "iPhone 13 Pro Max – 128 GB – Guter Zustand\n\nIch verkaufe mein iPhone 13 Pro Max mit 128 GB Speicher...",
  "description": null,
  "condition": null,
  "attributes": null,
  "sellerName": null,
  "sellerId": null,
  "sellerKind": null,
  "sellerShopUrl": null,
  "sellerSince": null,
  "sellerAdCount": null,
  "categoryId": null,
  "categoryPath": null,
  "latitude": null,
  "longitude": null,
  "detailFetched": false,
  "matchedKeyword": "iphone 13",
  "scrapedAt": "2026-10-23T19:00:35.214489+00:00"
}
Enter fullscreen mode Exit fullscreen mode

Understanding your results

The fields you will use most:

  • price: asking price in whole euros. It is null when the ad shows only "VB", and 0 for free ads.
  • priceNegotiable: true when the card carries VB (Verhandlungsbasis).
  • postcode and location: the five-digit postcode and the town or district on the card.
  • postedAt and postedDate: posting time (Europe/Berlin) and date.
  • shipping, buyNow, sellerWarranty: badges on the card (Versand möglich, Direkt kaufen, Verkäufergarantie).
  • isPromoted, isProShop, isWanted: how the card was placed and who posted it.

What our 60 rows show:

  • 59 ads had a numeric price, ranging from 4 EUR to 400 EUR with a median of 230 EUR. One ad was posted as "VB" with no figure.
  • 33 ads were marked negotiable and 27 had a fixed price. None were free (33 + 27 + 0 = 60).
  • 2 ads carried a previousPrice, for example an iPhone 13 mini listed at 199 EUR, down from 279 EUR.
  • All 60 offered shipping and all 60 came from sellers without a PRO badge, as the input asked. 22 had Direkt kaufen switched on.
  • 1 ad was a promoted placement; the other 59 were organic.

Pro tips and advanced usage

  1. Page size drives maxPages. A page holds 25 ads, so maxPages: 4 can look at about 100 cards. The site serves at most 50 pages, or 1,250 ads, per search.
  2. Narrow by category and place. Use categoryId and locationId from the numbers in the site's own URLs instead of searching all of Germany.
  3. Add detail pages when you need seller data. fetchDetails: true opens every kept ad and fills description, attributes, sellerKind and sellerSince. It adds one extra request per row, so runs are slower.
  4. Drop promoted cards. The Actor infers promoted cards from the missing posting date; set excludePromoted: true to remove them.
  5. Pricing: For current pricing and details, see the Store page.
  6. Automate with webhooks. A webhook's payload resource is the response of the Get Actor run API endpoint (docs.apify.com/platform/integrations/webhooks/actions), which includes defaultDatasetId.

Troubleshooting common issues

  • The run fails with "No rows pushed for any search". The Actor raises this error when nothing was saved. Either every ad was filtered out (a narrow price range plus shippingOnly, say) or the page layout changed.
  • The run fails with "Give at least one search term". You passed neither searchQueries nor a categoryId, and the Actor raises that error at startup. A locationId alone is not enough.
  • Input refused before the run starts. offerType and sellers accept only the values in the table above; anything else is refused by the platform before the run begins.
  • Cases and repair offers in the results. The site matches your words anywhere in the ad text. requireKeywordMatch is on by default and keeps only ads whose title and card text, read together, contain every search word.

For support, use the Actor's Store page.


Next steps

Save the search as a task and add an Apify schedule to repeat it, then compare price and previousPrice between runs, or feed the rows into a spreadsheet or database. Start with the Apify Python client documentation and the Store page.

What are you planning to look for on Kleinanzeigen? Drop a comment below!

Top comments (0)