# search.ch Scraper — Swiss Business & Phone Directory Leads (`worktech/search-ch-scraper`) Actor

Scrape Swiss business leads from search.ch (tel.search.ch): name, phone, street address, postal code, city, categories and website. Search by trade and place in German, French or Italian (Sanitär Zürich, Plomberie Genève). Business listings only — no private phone-book entries. $1.50 per 1,000.

- **URL**: https://apify.com/worktech/search-ch-scraper.md
- **Developed by:** [Per Schondell](https://apify.com/worktech) (community)
- **Categories:** Lead generation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$1.50 / 1,000 business records

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## search.ch Scraper — Swiss Business & Phone Directory Leads

The **search.ch scraper** (tel.search.ch) that turns Switzerland's national directory into a clean B2B lead list: company name, **phone number, street address, postcode, city, categories and website** for any trade in any Swiss town — in **German, French, Italian or English**. Type `Sanitär` + `Zürich`, press Start, export **Swiss business leads** to JSON, CSV or Excel.

**Business listings only.** search.ch *is* the Swiss phone book: on a live "sanitär in zürich" search its own facet counter reads *143 business and 17 private entries*. This scraper emits business entries only — private phone-book records cannot be produced, and there is no setting that turns them on. See [Business-only by design](#business-only-by-design).

### What can you do with it?

- **Firmen in Zürich mit Telefonnummer finden** — die Telefonnummer steht auf jeder Zeile im internationalen `+41`-Format, direkt für CRM oder Dialer
- **Entreprises à Genève avec numéro de téléphone** — cherchez `Plomberie` + `Genève` et exportez la liste en français
- **Trovare aziende in Ticino** — `Idraulico` + `Lugano` in italiano
- **Export search.ch / tel.search.ch results to CSV or Excel** — one clean row per business, stable columns
- **Build canton-by-canton prospect lists** — `Treuhand` in Zug, `Garage` in Bern, `Fiduciaire` in Lausanne
- **Respect the Swiss "no advertising" star automatically** — every record carries `refusesAdvertising`, so you can exclude the entries that legally may not be cold-contacted (UWG art. 3 para. 1 lit. u). Measured on a live page: **10 of 20** entries carry it
- **Join it with local.ch data** — `entryId` is localsearch's own identifier, the same one local.ch uses, so records from both directories line up
- **Feed AI agents and pipelines** — structured JSON via API, webhooks or the Apify MCP server

### Pricing

**$1.50 per 1,000 business records** ($0.0015 per record). You pay only for records actually delivered to your dataset — a failed or empty search costs nothing, because data is pushed to the dataset *before* it is charged, and a record is never pushed (or charged) twice.

| Scenario | Records | Run cost |
|----------|---------|----------|
| Plumbers in Zürich | ~160 | ~$0.24 |
| One trade across the 20 largest Swiss cities | ~2,500 | ~$3.75 |
| Three trades x 4 language regions | ~1,500 | ~$2.25 |

**No proxy required.** The default configuration uses no proxy at all — search.ch answered every probe over plain HTTP, no browser and no challenge — so the only platform cost on top of the per-record price is a few cents of compute.

### How to scrape search.ch (step by step)

1. Click **Try for free** — you need a free Apify account.
2. Pick the **Language** (`de`, `fr`, `it`, `en`) your search terms are written in.
3. Enter one or more **Search terms** in that language (e.g. `Sanitär`, `Elektriker`, `Treuhand`).
4. Enter **Locations** — Swiss cities, towns, postcodes or cantons (e.g. `Zürich`, `Bern`, `Genève`). At least one is required.
5. Optionally adjust **Max results per search** (default 100 per term x location; `0` walks to search.ch's own last page) and the request delay.
6. Click **Start**, then open the **Dataset** tab and export as JSON, CSV, Excel, HTML or XML.

Prefer pasting URLs? Put search.ch trade searches straight into **Start URLs** — e.g. `https://search.ch/tel/?was=sanit%C3%A4r&wo=z%C3%BCrich`.

#### Input example

```json
{
  "searchTerms": ["Sanitär", "Elektriker"],
  "locations": ["Zürich", "Winterthur"],
  "language": "de",
  "maxResultsPerSearch": 200,
  "requestDelayMs": 1000
}
```

#### Output example (one dataset record)

```json
{
  "name": "Wanner Sanitär GmbH",
  "phone": "+41442727664",
  "phones": ["+41442727664"],
  "email": null,
  "address": "Förrlibuckstrasse 110",
  "postalCode": "8005",
  "city": "Zürich",
  "categories": ["Sanitäre Anlagen und Installationen"],
  "website": null,
  "description": null,
  "refusesAdvertising": true,
  "cheNumber": null,
  "language": "de",
  "entryId": "p3CJRqEsW4oMCwaFyR96sg",
  "profileUrl": "https://search.ch/tel/zuerich/foerrlibuckstrasse-110/wanner-sanitaer-gmbh",
  "site": "search.ch",
  "sourceUrl": "https://search.ch/tel/?was=sanit%C3%A4r&wo=z%C3%BCrich",
  "searchTerm": "Sanitär",
  "location": "Zürich",
  "scrapedAt": "2026-08-18T12:34:58+00:00"
}
```

Missing values are explicit `null`, so CSV and Excel exports always have the same columns.

### Use it from code or AI agents

```bash
curl -X POST "https://api.apify.com/v2/acts/worktech~search-ch-scraper/run-sync-get-dataset-items?token=YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"searchTerms":["Sanitär"],"locations":["Zürich"],"language":"de","maxResultsPerSearch":100}'
```

The actor also works with n8n, Make, Zapier, webhooks and the **Apify MCP server**.

### Multilingual by design

search.ch publishes every result list in four languages, and this actor speaks all of them:

| Language | Example search | Result page search.ch serves |
|----------|----------------|------------------------------|
| `de` | `Sanitär` + `Zürich` | `/tel/?was=sanitär&wo=zürich` |
| `fr` | `Plomberie` + `Genève` | `/tel/Genève/Plomberie.fr.html` |
| `it` | `Idraulico` + `Lugano` | `/tel/Lugano/Idraulico.it.html` |
| `en` | `Plumber` + `Zurich` | `/tel/Zurich/Plumber.en.html` |

The language of each result page is written onto every record — read from the page search.ch actually served, not from your input — so a **Start URL** like `https://search.ch/tel/Gen%C3%A8ve/Plomberie.fr.html` produces records marked `fr` even when the run's language setting says something else. A mixed multi-language run stays sortable.

### Business-only by design

search.ch is a phone book with businesses in it, not the other way round — so the private entries are walled off in three independent layers:

1. **URL layer** — every URL the actor builds is a *trade + place* search. A lookup without a trade (a plain name or number search — the white pages) cannot be built, and such a URL in **Start URLs** is rejected before a single request is made.
2. **Record layer** — an entry is emitted only when search.ch marks it `Business`. That is an allowlist: an unknown marker is refused, not guessed at. search.ch omits the marker entirely when an entry publishes no phone number (3 of 20 entries on the captured page — 15%), so those are shape-tested against Swiss person-name rules instead: hyphenated names (`Anne-Marie Jensen`), particles (`von Arx`, `Da Silva`), `Mc`/`Mac` prefixes, titles (`Dr. Hans Weber`), block capitals (`HANS WEBER`), initials (`Weber A.`), the inverted form (`Weber, Hans`) and a bare surname (`Meier`). A declared legal form always wins, so `Anne-Marie Jensen GmbH` stays a company. The rule is deliberately asymmetric: refusing an unmarked bare name costs the occasional single-word brand — visible to you as a drop in the SUMMARY — while publishing it costs a private individual their privacy.
3. **Evidence layer** — every drop is counted and written to the run **SUMMARY** as `privatePersonEntriesDropped`, present even when it is `0`.

An entry that arrives with **no marker at all** is decided in two steps: anything it publishes that only a business has — a website, a trade category or a CHE/UID number — keeps it, and the name-shape test decides only when there is no such evidence. That order matters: on a live French page a real plumbing business with a website and four plumbing categories was withheld purely because its name is two capitalised words. The trade-word vocabulary is plural-tolerant, so `Alpes Sanitaires` reads as a business exactly like `Alpes Sanitaire` does — a listing that publishes nothing at all is still judged on the whole of its name.

There is deliberately **no `includePersons` input**, and a test asserts that no input in the schema can re-enable them.

### Why this scraper?

- **It handles search.ch's cumulative pagination correctly.** On search.ch, `pages=2` does not mean "page 2" — it re-serves entries 1-10 *and* adds 11-20. This actor uses that to fetch several pages per request (160 records in 4 requests instead of 16) and suppresses every repeated entry, so nothing is delivered — or charged — twice. The number suppressed is reported in the run SUMMARY.
- **A real end-of-pagination signal** — the walk stops on search.ch's own `data-maxpage`, never on "the page looked full".
- **The Swiss compliance flag other scrapers drop** — `refusesAdvertising` comes from the entry's own "no advertising" star, not from a lookalike attribute that disagrees with it entry by entry.
- **No silent failures** — if search.ch reports 160 hits and the parser can read none of them, the run **fails loudly** instead of handing you an empty dataset with a green tick. A burned search is a named error in the SUMMARY and never kills the other searches; a charge failure is never swallowed.
- **Honest accounting on a partial run** — if a search burns halfway, the records it already delivered stay in the dataset **and stay in the SUMMARY count**, and the run is not marked failed. That matters because failed runs get retried: an under-reported run is how you end up paying twice for the same rows.
- **Fast and cheap by construction** — plain HTTP, no headless browser, one request at a time by default.

### FAQ

**Do I need an API key or a search.ch account?**
No. You need a free Apify account; the actor needs no credentials and only reads pages that are public to any visitor.

**Can I export the leads to CSV or Excel?**
Yes — CSV, Excel, JSON, HTML or XML, from the Apify Console or the API.

**Do I get email addresses?**
No — search.ch does not publish email addresses on its result pages, so `email` is always `null` rather than a guess. If email matters, pair this with a directory that publishes it on the result page.

**Does it collect private persons?**
No, and it cannot. See [Business-only by design](#business-only-by-design).

**What is `refusesAdvertising`?**
Swiss directory entries can carry a star meaning "no advertising, please". Under UWG art. 3 para. 1 lit. u it is unfair competition to send unsolicited advertising to those entries. Every record carries the flag and the run SUMMARY counts them.

**Why is `cheNumber` usually null?**
The Swiss company identifier (CHE-xxx.xxx.xxx) is not published on search.ch result entries — measured across the captured pages, none carried one. The field is filled if an entry does expose it and is `null` otherwise; it is never inferred.

**How many results can I get per search?**
As many as search.ch itself lists: the walk follows its own `data-maxpage` (16 pages, 160 entries, for a Zürich plumbing search). Set **Max results per search** to `0` for everything, or leave the default of 100.

**Which language should I use?**
The one your search terms are in, matching the region: `de` for Zürich/Bern/Basel, `fr` for Genève/Lausanne, `it` for Lugano/Bellinzona.

**Which proxy should I use?**
**None — that is the default and the cheapest option.** Only turn a proxy on if failed searches show up in the run SUMMARY, and then pick Apify Proxy → RESIDENTIAL group, country CH. Avoid a country-targeted **datacenter** proxy: without that entitlement every request fails with `ProxyError: 407`.

**Is this legal / FADP / GDPR-compliant?**
The actor collects publicly published **business** contact information and structurally excludes private phone-book records. You still need your own lawful basis for B2B prospecting, you must honour the `refusesAdvertising` marker and Swiss marketing law, and you are the data controller for what you do with the export. When in doubt, consult a lawyer.

### Limitations

- **No email addresses** — search.ch does not publish them on result pages.

- **Websites only where published** — measured 4 of 20 entries on a live page (search.ch shows the link mainly on paid entries).

- **A location is required** — only search.ch's trade + place grammar is verified against the live site, so a nationwide sweep is refused rather than guessed at.

- **Search terms must match the chosen language.**

- **Records are per entry, not per organisation** — a company with several branches appears once per branch.

- **Sponsored ad blocks are not scraped** — only genuine directory entries are emitted.

- **Single-word and connective-style brand names can be dropped when the site publishes no business marker** — a listing called `Migros`, `Chez Marcel` or `Zur Alten Post`, with no legal form and no trade word, reads as a personal name to the business-only filter. Measured exposure on live captures is zero (3 of 20 captured entries have no marker and all three survive), and the usual directory forms (`Restaurant zur Alten Post`, `Hotel Zum Goldenen Löwen`, `Coop Genossenschaft`, `Le Petit Chalet SA`) are unaffected. It fails in the safe direction and every drop is counted in the run SUMMARY, so you can see exactly how many.

### Status

**v0.1** — 242 unit tests passing offline against pages captured from the live site, covering entry parsing, the business-only guards, the cumulative-pagination handling and its terminator, per-page language detection, billing order, partial-run accounting and input validation. Twenty-one guards are additionally verified by mutation: each one was broken on purpose and the matching test was seen to fail. The captured pages in this repository were pseudonymised before they were committed — every private mobile number, in every format it appeared in, and every personal name on a sole-trader listing was replaced, with the record counts left intact.

# Actor input Schema

## `searchTerms` (type: `array`):

Trades or keywords to search on search.ch, in the language selected below (e.g. Sanitär, Elektriker, Treuhand — or Plomberie, Électricien, Fiduciaire in French). Each term is combined with every location.

## `locations` (type: `array`):

Swiss cities, towns, postcodes or cantons to search in (e.g. Zürich, Bern, Genève, Lugano). At least one is required — search.ch's verified search grammar is trade + place.

## `language` (type: `string`):

Switzerland is multilingual and search.ch publishes each result list in four languages. Pick the one your search terms are written in: German for Zürich/Bern, French for Genève/Lausanne, Italian for Lugano.

## `startUrls` (type: `array`):

Paste search.ch trade searches directly, e.g. https://search.ch/tel/?was=sanit%C3%A4r\&wo=z%C3%BCrich — used instead of or in addition to search terms. A lookup without a trade (a name or number search in the Swiss white pages) and entry detail pages are rejected with a clear error.

## `maxResultsPerSearch` (type: `integer`):

Cap on business records per search term x location. 0 = no cap (walks to search.ch's own last page).

## `requestDelayMs` (type: `integer`):

Politeness delay between paginated requests.

## `proxyConfiguration` (type: `object`):

No proxy is needed — search.ch answered every probe over plain HTTP with no proxy, and running without one is both proven and much cheaper. Only turn a proxy on if you see failed searches in the run SUMMARY, and then choose Apify Proxy with the RESIDENTIAL group and country CH — a country-targeted DATACENTER proxy fails with 'ProxyError: 407 Proxy Authentication Required' on accounts without that entitlement.

## Actor input object example

```json
{
  "searchTerms": [
    "Sanitär"
  ],
  "locations": [
    "Zürich"
  ],
  "language": "de",
  "maxResultsPerSearch": 100,
  "requestDelayMs": 1000,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `businessRecords` (type: `string`):

No description

## `runSummary` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "searchTerms": [
        "Sanitär"
    ],
    "locations": [
        "Zürich"
    ],
    "proxyConfiguration": {
        "useApifyProxy": false
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("worktech/search-ch-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "searchTerms": ["Sanitär"],
    "locations": ["Zürich"],
    "proxyConfiguration": { "useApifyProxy": False },
}

# Run the Actor and wait for it to finish
run = client.actor("worktech/search-ch-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "searchTerms": [
    "Sanitär"
  ],
  "locations": [
    "Zürich"
  ],
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}' |
apify call worktech/search-ch-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,worktech/search-ch-scraper"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/t5zU0TU0y1WJm7AH9/builds/Izadg0TuNYSfvvQrI/openapi.json
