DEV Community

Cover image for How to Scrape Tutti.ch Classifieds in Python with Tutti.ch Scraper
ReventaData
ReventaData

Posted on Originally published at apify.com

How to Scrape Tutti.ch Classifieds in Python with Tutti.ch Scraper

By the end of this tutorial you will have a Python script that pulls Swiss classified listings from tutti.ch into structured rows: title, asking price, postcode, city, canton, timestamp, category, seller name and listing link.

tutti.ch sits behind a Cloudflare challenge, so a plain HTTP client does not reach the search pages. In my checks on 8 November 2026, direct requests from a laptop and from a datacenter server all received HTTP 403 challenge pages. Tutti.ch Scraper (reventadata/tutti-scraper) handles that for you through an unblocking service.

Pass one or more keywords and get one deduplicated row per listing. The sample data below comes from a live run on 14 November 2026 (20 rows in 7.5 seconds), plus one same-day detail page parsed with the Actor's parser.


Prerequisites

You need three things before you run the script:

  1. Python 3.11 or newer (required by the official apify-client 3.x library).
  2. A free Apify account, created with an email address or a GitHub login.
  3. Your Apify API token, copied from Settings > API & Integrations in Apify Console.

Install or upgrade the client and export your token in the shell:

pip install -U "apify-client~=3.0"
export APIFY_API_TOKEN="your_apify_token_here"
Enter fullscreen mode Exit fullscreen mode

No browser automation or proxy setup is needed on your machine. The Actor fetches and parses the pages on Apify.


What you'll achieve

You will run a short Python script that searches tutti.ch for "iphone 13 pro", caps the output at 20 listings on page 1, and prints a clean summary line for the first five results.

Searching a single keyword on tutti.ch returns everything matching that phrase across categories: phones, chargers, cases and bundle deals all appear together. You will see how to inspect the categoryId and isHighlighted fields so you can keep real handsets and drop accessories or paid placements.

The same script works for any search keyword across Switzerland and Liechtenstein. Only the run_input dictionary changes.


Step 1: Open Tutti.ch Scraper

Open the Tutti.ch Scraper page on Apify Store. The Try for free button opens the Actor in Apify Console if you prefer to test inputs in the web form first. For this Python tutorial, you only need the Actor identifier: reventadata/tutti-scraper.


Step 2: Configure your search inputs

The Actor takes four input fields:

  • searchQueries: required list of keywords (for example ["iphone 13 pro"]). Each keyword is searched on its own across all categories. An empty list, blank strings, or a keyword over the 200-byte UTF-8 limit ends the run as FAILED before any page is requested.
  • maxItems: hard cap on rows returned across all keywords combined (default 100).
  • maxPages: optional cap on result pages per keyword, from 1 to 101. Each page holds up to 30 listings, and tutti.ch stops serving search results after page 101.
  • includeDetails: boolean (default false). Keep it off for fast search sweeps; turn it on when you need full descriptions, coordinates, all image links, listing properties and seller account metadata.

A multi-keyword configuration without detail fetches looks like this:

{
    "searchQueries": ["iphone 13 pro", "iphone 14 pro"],
    "maxItems": 50,
    "maxPages": 2,
    "includeDetails": false
}
Enter fullscreen mode Exit fullscreen mode

A listing that appears under both keywords, or shifts between pages while a run walks them, is stored once by its listing id.


Step 3: Run the Actor from Python

This script starts the Actor, waits for it to finish, reads the dataset and prints the first five rows:

import os
from apify_client import ApifyClient

# 1. Initialise the client with your token from the environment
client = ApifyClient(os.environ["APIFY_API_TOKEN"])

# 2. Describe the search: one keyword, first page, up to 20 rows
run_input = {
    "searchQueries": ["iphone 13 pro"],
    "maxItems": 20,
    "maxPages": 1,
    "includeDetails": False,
}

# 3. Start the Actor and wait for it to finish
run = client.actor("reventadata/tutti-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. Read the rows from the run's default dataset
items = client.dataset(run.default_dataset_id).list_items().items
print(f"Retrieved {len(items)} listings.\n")

# 5. Print the first 5 listings with price, place, canton and category
for item in items[:5]:
    price = item.get("priceText") or "no price"
    print(f"{item['title']} | {price} | {item.get('city')}, {item.get('canton')} | {item.get('categoryId')}")
Enter fullscreen mode Exit fullscreen mode

Because each search page passes through an unblocking service, keep maxPages small while you test your filters.


Step 4: Inspect the output

The live example run on 14 November 2026 returned 20 listings in 7.5 seconds. Running that loop over its dataset produces this output:

Retrieved 20 listings.

MagSafe Ladegerät (1 m) für iPhone von A-Tec * NEU + OVP | 7.- | Ostermundigen, BE | phoneNavigationAccessories
iPhone 13 Pro Max Gold (Gold) Sehr guter Zustand | 120.- | Lugano, TI | cellPhones
Apple iPhone 13 Pro Max 256 GB Graphit Sehr gepflegt | 295.- | Wallisellen, ZH | cellPhones
Neue Handyhüllen ? verschiedene Modelle (iPhone & Samsung) | 8.- | Bremgarten AG, AG | phoneNavigationAccessories
iPhone 13 Pro 128 GB - Akkukapazität 100% - wie neu - OVP | 279.- | Grabs, SG | cellPhones
Enter fullscreen mode Exit fullscreen mode

Notice right away that rows 1 and 4 are a charger (7.-) and phone cases (8.-) in phoneNavigationAccessories, while rows 2, 3 and 5 are handsets in cellPhones.


Step 5: Enable detail pages (optional)

Set includeDetails to true when you need full listing texts, coordinates, properties or all images. The Actor fetches up to three detail pages concurrently per search page. If one detail page fails to load, the row still ships with its 11 detail fields set to null and a warning in the log.

Here are the 11 detail fields from a same-day capture of listing 83806999 on 14 November 2026, parsed with the Actor's own parser (imageUrls cut to its first entry; description shortened):

{
    "description": "Apple iPhone 13 Pro Max  256 GB  Graphit  Sehr gepflegt\n\nPreis: CHF 295 VB\n\nIch verkaufe mein Apple iPhone 13 Pro Max mit 256 GB Speicher in der Farbe Graphit (Grau), da ich auf ein neues Modell umsteige...",
    "latitude": 47.42120361328125,
    "longitude": 8.596273422241211,
    "imageUrls": [
        "https://c.tutti.ch/big/1360495343.jpg"
    ],
    "properties": [
        {
            "label": "Versand verfügbar",
            "text": "Nein"
        },
        {
            "label": "Zustand",
            "text": "Neu"
        }
    ],
    "categoryLabel": "Handys",
    "parentCategoryLabel": "Telefon & Navigation",
    "sellerPublicAccountId": "9169092695698416259",
    "sellerMemberSince": "2026-10-13T09:47:39Z",
    "sellerProfileUrl": null,
    "detailPrice": 295
}
Enter fullscreen mode Exit fullscreen mode

Phone numbers and street addresses are not read by the Actor.


Understanding your results

Each row has 35 fields: id, url, title, descriptionSnippet (about 130 characters), integer price, raw priceText (like 295.- or 1'490.-), postcode, city, canton (including "FL" for Vaduz), cantonName, postedAt, categoryId, sellerName, sellerLogoUrl, sellerBadgeUrl, thumbnailUrl, imageCount, isHighlighted, source, language, 11 detail fields (null unless enabled), matchedKeyword, searchUrl, page and scrapedAt. Complete row from 14 November 2026:

{
    "id": "83806999",
    "url": "https://www.tutti.ch/de/vi/83806999",
    "title": "Apple iPhone 13 Pro Max 256 GB Graphit Sehr gepflegt",
    "descriptionSnippet": "Apple iPhone 13 Pro Max  256 GB  Graphit  Sehr gepflegt\n\nPreis: CHF 295 VB\n\nIch verkaufe mein Apple iPhone 13 Pro Max mit 256 GB Spei…",
    "price": 295,
    "priceText": "295.-",
    "postcode": "8304",
    "city": "Wallisellen",
    "canton": "ZH",
    "cantonName": "Zürich",
    "postedAt": "2026-11-14T11:18:13+01:00",
    "categoryId": "cellPhones",
    "sellerName": "Sai Sagar R",
    "sellerLogoUrl": null,
    "sellerBadgeUrl": "https://cdn.tutti.ch/subscriptions-logo/plus.svg",
    "thumbnailUrl": "https://c.tutti.ch/thumbnail/1360495343.jpg",
    "imageCount": 7,
    "isHighlighted": false,
    "source": "tutti",
    "language": "FR",
    "description": null,
    "latitude": null,
    "longitude": null,
    "imageUrls": null,
    "properties": null,
    "categoryLabel": null,
    "parentCategoryLabel": null,
    "sellerPublicAccountId": null,
    "sellerMemberSince": null,
    "sellerProfileUrl": null,
    "detailPrice": null,
    "matchedKeyword": "iphone 13 pro",
    "searchUrl": "https://www.tutti.ch/de/q/suche/Ak61pcGhvbmUgMTMgcHJvwJTAwMDA?page=1",
    "page": 1,
    "scrapedAt": "2026-11-14T14:45:13.000Z"
}
Enter fullscreen mode Exit fullscreen mode

Pro tips

  1. Filter by categoryId in Python. In the 20 rows from 14 November 2026, 14 were in cellPhones, 4 in phoneNavigationAccessories, 1 in computers and 1 in computerComponentsAccessories. Check title too, since one phone case listing sat inside cellPhones.
  2. Watch isHighlighted. tutti.ch shows paid highlighted placements that can have nothing to do with your keyword. The Actor keeps them with isHighlighted: true so you can filter them out cleanly.
  3. Keep includeDetails off until you need it. Search pages alone take 12 to 62 seconds per page in our tests (median around 22 s; the 14 November run took 7.5 s), and details add one request per kept listing.
  4. Stay under the 101-page ceiling. tutti.ch redirects past page 101 (3,030 results) to the all-categories feed; the Actor stops cleanly when it detects that redirect, so split huge queries into narrower keywords.
  5. Handle null prices safely. Non-numeric price text yields price: null rather than 0, so guard against None before averaging.

Troubleshooting

  • Zero rows with status SUCCEEDED: tutti.ch answered normally and returned zero matches for your keyword. The run's status message names the keyword and page count; try a broader query.
  • Zero rows with status FAILED: Either searchQueries was empty, blank or over the 200-byte UTF-8 limit, or the Actor could not load a valid page (block or layout change). Inspect the log with client.log(run.id).get().
  • Fewer rows than totalCount: Check the Skipped N: reason lines at the end of the run log for cross-page or cross-keyword duplicates, or check whether maxItems or maxPages stopped the sweep early.

Review tutti.ch's terms of use and Swiss data-protection rules before redistributing or storing personal seller data.


Next steps

For current pricing and details, see the Store page.

To automate recurring price checks, save your input as an Apify task and attach a schedule. You can export datasets as JSON, CSV, Excel or JSONL (dataset docs), or trigger your own endpoint via webhooks when a run finishes.

What are you planning to search for on tutti.ch? Drop a comment! 💬

About the author: reventadata builds and publishes data extraction Actors on the Apify Store.

Top comments (0)