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

Scrape Swiss business leads from local.ch: company name, phone, mobile, email, website, street address, postal code, city and canton. Search by trade and place in German, French or Italian (Sanitär Zürich, Plomberie Genève). Business listings only — no private persons. $1 per 1,000 records.

- **URL**: https://apify.com/worktech/local-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.00 / 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/platform/actors/running/actors-in-store#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

## local.ch Scraper — Swiss Business Directory Leads

The **local.ch scraper** that turns Switzerland's biggest business directory into a clean B2B lead list: company name, **phone, mobile, email, website, street address, postcode, city and canton** 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.** local.ch is Switzerland's phone book as well as its business directory — on a live "Sanitär Zürich" page local.ch's own counters read *339 results, 334 of them businesses*. This actor emits only entries local.ch marks as a business; private persons 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** — jede Zeile trägt die Telefonnummer im internationalen `+41`-Format, direkt für CRM oder Dialer
- **Entreprises à Genève avec numéro de téléphone** — cherchez `Plomberie` + `Genève` en français et recevez des fiches en français
- **Get Swiss business leads with email addresses** — email comes straight out of the search result: **measured 15 of 20 businesses on page 1 and 20 of 20 on page 5** of a Zürich search. No detail crawl, no extra cost
- **Trovare aziende in Ticino** — Italian searches (`Idraulico` + `Lugano`) return Italian-language listings
- **Export local.ch search results to CSV or Excel** — one clean row per business, stable columns, ready to import
- **Build canton-by-canton prospect lists** — `Treuhand` in Zug, `Garage` in Bern, `Fiduciaire` in Lausanne
- **Respect the Swiss "no advertising" marker automatically** — every record carries `refusesAdvertising`, so you can exclude the businesses that legally may not be cold-contacted (UWG art. 3 para. 1 lit. u). Measured: **14 of 20** businesses on the captured page carry it
- **Feed AI agents and pipelines** — structured JSON via API, webhooks or the Apify MCP server

### Pricing

**$1 per 1,000 business records** ($0.001 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.

| Scenario | Records | Run cost |
|----------|---------|----------|
| Plumbers in Zürich | ~100 | ~$0.10 |
| One trade across the 20 largest Swiss cities | ~2,000 | ~$2.00 |
| Three trades x 4 language regions | ~1,200 | ~$1.20 |

There is **no enriched tier and no per-company detail crawl**: email, website, mobile, canton and coordinates are already in the search results, so everything is delivered at the single per-record price.

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

### How to scrape local.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`, or `Plomberie`, `Électricien`).
4. Enter **Locations** — Swiss cities, towns or postcodes (e.g. `Zürich`, `Bern`, `Genève`, `Lugano`). At least one is required.
5. Optionally adjust **Max results per search** (default 100 per term x location) and the request delay. Leave **Proxy configuration** off — it is not needed.
6. Click **Start**, then open the **Dataset** tab and export as JSON, CSV, Excel, HTML or XML.

Prefer pasting URLs? Put local.ch search URLs straight into **Start URLs** instead of search terms — e.g. `https://www.local.ch/de/q/zuerich/sanitaer`.

#### Input example

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

#### Output example (one dataset record)

```json
{
  "name": "A. Baltensperger AG",
  "phone": "+41443665070",
  "phones": ["+41443665070"],
  "mobile": null,
  "fax": "+41443665080",
  "email": "info@baltensperger.ch",
  "emails": ["info@baltensperger.ch"],
  "website": "http://www.baltensperger.ch",
  "socialMediaUrls": ["https://www.facebook.com/A-Baltensperger-AG-326233344933861"],
  "address": "Langmauerstrasse 65",
  "postalCode": "8006",
  "city": "Zürich",
  "canton": "ZH",
  "latitude": 47.391818,
  "longitude": 8.542592,
  "categories": ["Haustechnik", "Sanitär", "Heizungen", "Lüftung", "Renovation", "Badezimmerrenovation", "Solartechnik Solaranlagen"],
  "reviewScore": 5,
  "reviewCount": 1,
  "refusesAdvertising": true,
  "cheNumber": null,
  "language": "de",
  "isPremium": true,
  "profileUrl": "https://www.local.ch/de/d/zuerich/8006/haustechnik/a-baltensperger-ag-NBlHPNWpqBhH0KmssCHc7w",
  "site": "local.ch",
  "sourceUrl": "https://www.local.ch/de/q/zuerich/sanitaer",
  "searchTerm": "Sanitär",
  "location": "Zürich",
  "scrapedAt": "2026-08-18T12:29:30+00:00"
}
```

That is a real record, byte for byte, as this actor produced it from a live local.ch page — a unit test re-parses this very JSON block and diffs it against the parser, so it cannot drift. Note `refusesAdvertising: true` on this one: that business may not be sent unsolicited advertising.

Missing values are explicit `null`, so CSV and Excel exports always have the same columns. Not every business publishes an email — see the coverage numbers above.

### Use it from code or AI agents

```bash
curl -X POST "https://api.apify.com/v2/acts/worktech~local-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** — its flat, structured output is designed to be consumed by AI agents directly.

### Multilingual by design

Switzerland has four language regions and local.ch serves a **different result set per language**, not a translated one. This actor exposes that:

| Language | Example search | Example URL local.ch normalises to |
|----------|----------------|-------------------------------------|
| `de` | `Sanitär` + `Zürich` | `/de/q/zuerich/sanitaer` |
| `fr` | `Plomberie` + `Genève` | `/fr/q/geneve/plomberie` |
| `it` | `Idraulico` + `Lugano` | `/it/q/lugano/idraulico` |
| `en` | `Plumber` + `Zurich` | `/en/q/zurich/plumber` |

Every record also carries the **`language` local.ch itself assigned to that listing**, so a mixed run stays sortable. And you never have to guess how a Swiss place name transliterates: the actor sends `where=Zürich` and local.ch answers with its own slug (`zuerich`), so umlauts, accents and bilingual town names resolve the way local.ch resolves them.

### Business-only by design

local.ch indexes private individuals next to companies. Personal data is not what a lead-generation tool should be shipping, so the phone book is walled off in four independent layers:

1. **URL layer** — every URL the actor builds is a *trade + place* search. A location-only search (which lists everyone in town) cannot be built, and a location-only URL in **Start URLs** is rejected before a single request is made.
2. **Page layer** — any page local.ch marks as a phone-book search is discarded whole, not parsed.
3. **Record layer** — only entries local.ch marks `BUSINESS` are emitted. That is an allowlist: an unknown entry type is refused, not guessed at. An entry that arrives with *no* marker at all is additionally shape-tested against Swiss person-name rules, which cover 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.
4. **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.

That last 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.

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

Sole traders that local.ch itself lists as businesses (e.g. `Caseri Walter`) are *registered businesses* and legitimate B2B leads — they are kept.

### Why this scraper?

- **Email and mobile at the base price** — both are in the search result, so there is no detail-page crawl, no enrichment surcharge and no proxy blow-up. Measured coverage on live pages: 15/20 and 20/20 with an email.
- **A real end-of-pagination signal** — the walk stops on local.ch's own hit counter and its own `maxResultWindow`, never on "the page looked full". And if local.ch ever ignores the `page` parameter, the run **fails loudly** instead of re-emitting page 1 over and over — and charging you for each copy.
- **The Swiss compliance flag other scrapers drop** — `refusesAdvertising` is published as a first-class field.
- **Genuinely multilingual** — de/fr/it/en searches with the per-listing language on every row.
- **No silent failures** — a burned search is a named error in the run **SUMMARY** (with the status that burned it) and never kills the other searches; a charge failure is never swallowed; and you are never billed for records you did not receive, because records are pushed before they are charged. If local.ch renames the fields this scraper reads, the run fails loudly instead of handing you an empty dataset with a green tick.
- **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 page at a time by default.

### FAQ

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

**Can I export the leads to CSV or Excel?**
Yes. Every run produces a dataset downloadable as CSV, Excel, JSON, HTML or XML from the Apify Console, or via the API.

**Do I get email addresses?**
Yes, whenever local.ch publishes one — it is part of the search result, so it costs nothing extra. Coverage is high in Swiss cities (measured 15/20 and 20/20 on captured pages) and lower in small villages.

**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 marker meaning "no advertising, please". Under UWG art. 3 para. 1 lit. u it is unfair competition to send unsolicited advertising to those entries. The actor publishes the flag on every record so you can filter them out; the run SUMMARY also counts them.

**Why is `cheNumber` usually null?**
The Swiss company identifier (CHE-xxx.xxx.xxx) is *not* published on local.ch's search result pages — measured across 60 live entries, zero carried one. The field is filled when a listing does expose it and is otherwise `null`; it is never inferred or guessed from another source.

**How many results can I get per search?**
The default is **100 records (5 result pages) per search term x location** — and that default is **ours, not local.ch's**. It was set after local.ch answered HTTP 403 on result pages 10, 17 and 18 during development; on later runs the same deep pages were served normally (page 17 returned 200 with 19 records), so the limit appears to vary rather than being fixed. Raise **Max results per search** — or set it to `0` — if you want deeper result sets, and watch the run SUMMARY: if the wall does reappear, the records already collected are kept and the search is listed under `depthBlockedSearches`, so a short result set is never mistaken for a complete one. Splitting a search by postcode or neighbouring town also works and is gentler on the site.

**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/Neuchâtel, `it` for Lugano/Bellinzona. Searching German terms in the French region returns few hits — that is local.ch's behaviour, not the actor's.

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

**Is this legal / FADP / GDPR-compliant?**
The actor collects publicly published **business** contact information — the same data any visitor to local.ch sees — and structurally excludes private-person 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

- **Coverage is whatever local.ch publishes** — businesses without a listed email or website come back with `null` in those fields.

- **100 records per search term x location by default** — this is the actor's own cap, not a hard site limit: local.ch refused deep pages with HTTP 403 during development but has served them since. Raise it (or set `0`) for deeper sets; if the wall reappears the run SUMMARY reports it under `depthBlockedSearches`.

- **A location is required** — only local.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** — local.ch matches its own category vocabulary per language region.

- **Records are per listing, not per organisation** — a company with several branches appears once per branch, which is usually what you want for local outreach.

- **`cheNumber` is usually null** — local.ch does not publish UID numbers on search results.

- **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 (all 80 captured entries carry local.ch's own BUSINESS marker, so the name rule is never consulted), 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** — 257 unit tests passing offline against pages captured from the live site, covering the RSC payload parser and its reference resolution, every dataset field, the business-only guards, pagination termination, the 403 depth wall, billing order, partial-run accounting and input validation. Twenty 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, every consumer mailbox 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 local.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 or postcodes to search in (e.g. Zürich, Bern, Genève, Lugano). At least one is required — local.ch's verified search grammar is trade + place.

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

Switzerland is multilingual and local.ch serves a different result set per language. Pick the language your search terms are written in: German for Zürich/Bern, French for Genève/Lausanne, Italian for Lugano.

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

Paste local.ch search URLs directly, e.g. https://www.local.ch/de/q/zuerich/sanitaer — used instead of or in addition to search terms. Only trade+place searches are accepted; a location-only URL (which lists private persons too) and company detail pages are rejected with a clear error.

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

Cap on business records per search term x location. 100 (5 result pages) is what local.ch reliably serves per search — deeper pages are refused by local.ch itself. 0 = no cap.

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

Politeness delay between paginated search requests.

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

No proxy is needed — local.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/local-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/local-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/local-ch-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,worktech/local-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/nHxXjRzEhi6IQretb/builds/CIdjFiCmTCwLnp74b/openapi.json
