Skip to main content

Complete Guide: How to Scrape Kleinanzeigen Ads Using Python and Apify

Run the Kleinanzeigen Scraper Actor from Python and get iPhone 13 ads from private sellers with shipping as clean, typed JSON rows.

Updated 13 min readMarkdown
Complete Guide: How to Scrape Kleinanzeigen Ads Using Python and Apify

This complete guide to Kleinanzeigen Scraper shows how to collect ads from kleinanzeigen.de, the German classifieds portal formerly known as eBay Kleinanzeigen, from Python through the Apify API. Instead of copying listings by hand, you send one JSON input and receive one structured row per ad: title, price in whole euros, a flag for negotiable prices, postcode and town, posting time in the Europe/Berlin zone, shipping and buy-now flags, seller badges, photo count and the ad URL.

A single search such as "iphone 13" mixes private sellers with shops, offers with wanted ads, and ads with and without shipping. The Actor applies those distinctions as filters on the rows, so the dataset already matches your question. Here the question is concrete: iPhone 13 ads from private sellers that offer shipping.

You will install the Apify client, run the Actor from a short script, and read the rows. You need Python, a terminal and an Apify account token. Estimated time to complete: About 30 minutes. The run cited throughout (task input searchQueries: ["iphone 13"], sellers: "private", shippingOnly: true, maxItems: 60, maxPages: 4) returned 60 rows in 9.3 seconds.


TL;DR

In this tutorial, you'll learn to run the Kleinanzeigen Scraper Actor from Python, read its dataset, and filter kleinanzeigen.de ads by seller kind and shipping. The key steps: install apify-client, put your token in the script, define run_input with a search term and the sellers and shippingOnly filters, call the Actor, and iterate over the default dataset.

Expected outcome: 60 typed rows for the cited run, each with a title, a price, a currency, a location and an ad URL, ready for a spreadsheet, a database or a price tracker.

The Actor is available at apify.com/reventadata/kleinanzeigen-scraper.


Prerequisites and setup

To follow this tutorial you need three things:

  1. Python 3 on your machine.
  2. The Apify API client for Python, installed with pip.
  3. An Apify account and its personal API token.

Apify Console is the web interface where Actors, runs, datasets and tokens are managed, and your token is on the API & Integrations page there. The platform documentation lives at docs.apify.com.

Install the client:

pip install "apify-client~=3.0"

Keep the token out of your source control. The script below uses the placeholder <YOUR_APIFY_TOKEN>; replace it with your own token or read it from an environment variable in your own setup.


Understanding Kleinanzeigen Scraper

Kleinanzeigen Scraper (reventadata/kleinanzeigen-scraper) searches kleinanzeigen.de by keyword, category or place and returns each matching ad as one row. Technically it requests the site's public search result pages, 25 ads per page, and parses each ad card into a record. The site serves at most 50 pages per search, so one search term can yield up to 1,250 ads.

Key capabilities:

  • Filters applied to rows: price range, shipping, seller kind and offer type are applied after the page is fetched, so they work in every category.
  • Wanted ads separated: cards marked "Gesuch" (someone looking to buy) are dropped by default.
  • Promoted cards flagged: cards shown without a posting date are returned with isPromoted set to true.
  • Optional detail pages: with fetchDetails on, the Actor opens each kept ad for its full text, attributes and seller block.

Limitations to know: "private" means a card without the PRO shop badge, so a commercial seller without that badge is listed as private; and the keyword filter looks only at the title and the card's short text, not at the full ad.


Step-by-step walkthrough

Step 1: Accessing the Actor

Open the Kleinanzeigen Scraper page on the Apify Store. It has a "Try for free" button that opens Apify Console, where you can fill the same input in a form. This guide runs the Actor from Python instead, so the same script can later sit inside a scheduled job.

Step 2: Configuring input parameters

The input is a JSON object. The fields used in this guide:

Field Type Meaning
searchQueries array One search per term across kleinanzeigen.de.
sellers string any (default), private or proShops.
shippingOnly boolean Keep only ads marked "Versand möglich" (shipping possible).
maxItems integer Hard cap on rows pushed across all search terms.
maxPages integer Pages per search term, 1 to 50, at 25 ads a page.

Three complete configurations:

  • Private iPhone 13 ads with shipping (this guide): {"searchQueries": ["iphone 13"], "sellers": "private", "shippingOnly": true, "maxItems": 60, "maxPages": 4}
  • Everything for a term, any seller: {"searchQueries": ["fahrrad"], "maxItems": 100}
  • Price band: {"searchQueries": ["iphone 13"], "minPrice": 150, "maxPrice": 300, "maxItems": 50}

Step 3: Advanced configuration

Beyond the basics, the input schema offers:

  • categoryId: the number after the c in a category URL on the site, for example 173 for smartphones and phones. It narrows every search term to that category.
  • locationId: the number after the l in a search URL, for example 3331 for Berlin. Without it the search covers all of Germany.
  • offerType: offers (default), wanted or all.
  • excludePromoted: drops the undated promoted cards.
  • requireKeywordMatch: on by default; it drops ads where a word of your term appears in neither the title nor the card text.

Price bounds are applied after fetching, because the site's own price filter path is disallowed in its robots.txt. An ad with only "VB" and no numeric price is dropped whenever a bound is set.

Step 4: Optional enrichment with ad pages

Set fetchDetails to true and the Actor opens every kept ad. The row then carries the full description, the structured attributes as written in German (brand, model, storage and so on), seller name and kind (private or commercial), member-since date, category path and the approximate coordinates the ad page publishes. It adds one extra page request per row. The cited run left it off, which is why the sample row below shows detailFetched as false and the detail fields as null.

Step 5: Running the Actor

This is the script for the cited input. It calls the Actor, waits for it to finish and prints one line per ad:

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"])

.call() starts the run and blocks until it ends, then returns the run record, whose default_dataset_id attribute points at the rows. The cited run ended with status SUCCEEDED after 9.3 seconds and pushed 60 rows.

Step 6: Examining the results

The last loop streams the dataset with iterate_items(). In the cited run, the first row was this ad, copied from the dataset:

{
  "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"
}

Reading it: price is whole euros and priceNegotiable is true, so the ad shows 290 euros with VB. postedAt carries a Berlin offset while scrapedAt is UTC. isProShop is false and shipping is true, as the filters required. snippet is the card text, cut by the site at about 100 characters.

Step 7: Downloading and exporting data

The dataset can be downloaded as JSON, CSV, Excel or JSONL, and the API serves the same rows to your own code. JSON keeps nested fields such as attributes; CSV suits a quick spreadsheet.


Working with the API

Everything above is the Apify API through the Python client. The .call() method blocks until the run finishes. When you do not want to hold a connection open, client.actor("reventadata/kleinanzeigen-scraper").start(run_input=run_input) returns the run object immediately, with its id and status, and you read the dataset later.

Authentication is the API token: pass it to the client constructor and never commit it. To read results, use client.dataset(run.default_dataset_id) and either iterate_items() to stream or list_items() for a page of rows.

For event-driven setups, add an Apify webhook to the run when you start it through the API and subscribe to ACTOR.RUN.SUCCEEDED. The webhook's resource is the object you would get from the relevant API at that moment; for a run event that is the Actor run, which carries defaultDatasetId (docs.apify.com/platform/integrations/webhooks/actions). Your endpoint can then fetch the rows without polling, once it has confirmed the run succeeded.

Error handling: check that the run's status is SUCCEEDED before you read rows, and wrap the call in your own retry logic if a job must not be missed. If the Actor pushes zero rows, it raises an error and the run fails rather than looking like an empty success, so a failed run in a pipeline means "look at the log", not "no ads exist".


Advanced usage patterns

1. Several search terms in one run

Pass more than one term, for example ["iphone 13", "iphone 14"]. The Actor walks each term in turn inside a single run and writes everything to one default dataset. Each row records the term that found it in matchedKeyword. An ad id is remembered once it is kept, so the same ad is not pushed twice in a run.

2. Narrow by category and place

Use categoryId and locationId to cut a broad search down. A search term plus a category keeps accessories and repair offers out of a phone search; a place keeps the results regional. Both numbers come from the site's own URLs, as described in Step 3.

3. Scheduled monitoring

To watch a market over time, run the same input on a timetable. An Apify schedule runs an Actor or task on a cron expression (docs.apify.com/platform/schedules). Compare two runs by id: the same id with a different price is a change you detected, while previousPrice is a drop the seller made on the card.

4. Separating shops from private sellers

Run once with sellers: "private" and once with sellers: "proShops" to keep the two groups apart, or use any and split on the isProShop field. For a sharper private-or-commercial label, turn on fetchDetails and read sellerKind, the site's own wording.


Understanding your data

The main fields of each row:

  • id, title, url: ad number, title and link.
  • price, currency, priceNegotiable, isFree, previousPrice: whole euros (null when the card shows only "VB"), the VB flag, the "Zu verschenken" free flag, and the struck-through earlier price.
  • postcode, location: postcode and town or district.
  • postedAt, postedDate: posting time in Europe/Berlin when the card shows a clock time, and the date; promoted cards have no date.
  • shipping, buyNow, isProShop, isPromoted, isWanted: badge flags.
  • condition, attributes, sellerName, sellerKind, categoryPath: detail only.

What the cited run showed, computed from its 60 rows: 59 ads had a numeric price, ranging from 4 to 400 euros, with a median of 230 and a mean of 208.36. Prices split into 33 negotiable (VB), 27 fixed and 0 free, which sums to 60; one VB ad showed no figure, so its price is null. Two ads carried a previousPrice: 199 euros (was 279) on an iPhone 13 mini and 335 euros (was 350) on an iPhone 13 Pro. 22 ads offered Direkt kaufen and 38 did not, 1 was a promoted placement, and the rows named 57 distinct towns.


Troubleshooting guide

  • The run fails with "No rows pushed": every listing was filtered out, or the page layout changed. Loosen sellers, shippingOnly or the price bounds, and check that the term returns ads on the site.
  • The input is refused before the run starts: offerType and sellers are enums in the input schema, so a value outside the list is rejected by the platform. With neither a search term nor a categoryId, the Actor itself stops at start-up and asks for one.
  • Accessories or repair offers appear: the site matches the full ad text. Keep requireKeywordMatch on, and add categoryId for a tighter result.
  • Fewer rows than maxItems: the term may simply have fewer matching ads after your filters, or the site's 50-page ceiling was reached.
  • A page is missing: a page that keeps failing is skipped with a warning in the log, and the run goes on with the next page.
  • Reading the log: open the run in Apify Console and read the log; the last lines list rejected rows by reason and the number of rows pushed. For help, use the Store page.

Best practices

  1. Start small. Test with a low maxItems and maxPages, then raise them.
  2. Always set maxItems. It is a hard cap on rows pushed across all search terms, and maxPages limits how deep each term goes.
  3. Treat "private" as "no PRO badge". If the difference matters, enable fetchDetails and read sellerKind.
  4. Keep requireKeywordMatch on for product searches, and use category and place IDs to reduce noise.
  5. Re-run for freshness. Schedule the input and compare datasets by id to see which ads are new, gone or repriced.
  6. Mind the site. categoryId and locationId narrow the search itself; the price, seller, shipping and keyword filters run on the fetched rows. Set these input fields instead of looking for URL tricks.

Pricing model

For current pricing and details, see the Store page.


Conclusion and next steps

You now have a script that turns a kleinanzeigen.de search into a typed dataset: a search term, two filters, a cap on rows, and one loop to read them. From here you can add more terms, narrow by category and place, switch on ad pages for seller and attribute data, or put the input on a schedule.

Review the Apify documentation for schedules, webhooks and dataset exports, and the Actor page for the input form.

Try Kleinanzeigen Scraper yourself and let me know what you build!


I build and maintain marketplace scrapers under the reventadata name on the Apify Store.

About the author

  • Hi, I’m the engineer behind ReventaData. I build automated web scrapers, reverse-engineer undocumented APIs, and design data pipelines on Apify that turn complex, unstructured websites into clean, reliable feeds. Here on Hashnode, I share practical tutorials on modern web scraping with Python, bypassing aggressive anti-bot protections (Cloudflare, Akamai, PerimeterX), and data pipeline automation, reverse-engineering hidden endpoints, and automating data extraction. Explore our turnkey scrapers: https://apify.com/reventadata

No comments yet. Be the first to comment.

More from this blog