# 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](http://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](http://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](https://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](https://docs.apify.com/).

Install the client:

```bash
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](http://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](https://apify.com/reventadata/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](http://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:

```python
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:

```json
{
  "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](https://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](https://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](https://apify.com/reventadata/kleinanzeigen-scraper).

---

## Conclusion and next steps

You now have a script that turns a [kleinanzeigen.de](http://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](https://docs.apify.com/) for schedules, webhooks and dataset exports, and the [Actor page](https://apify.com/reventadata/kleinanzeigen-scraper) 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.*
