# Yellow Pages Scraper - BBB, Europages, Business Directory (`snow_leo_data/yellow-pages-scraper`) Actor

Yellow Pages, BBB, Gelbe Seiten and Europages scraper in one run: 524 businesses from a BBB search that hands a human 175, measured. 7 directories, 38 columns, 11 filters that run before billing. Yellow pages data scraper, BBB business search, Europages directory.

- **URL**: https://apify.com/snow\_leo\_data/yellow-pages-scraper.md
- **Developed by:** [Snow Leo Data](https://apify.com/snow_leo_data) (community)
- **Categories:** Lead generation, Automation, MCP servers
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$2.20 / 1,000 businesses

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?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## 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.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Business Directory Scraper — Yellow Pages, BBB, Gelbe Seiten, PagineGialle, Europages, Hotfrog

Seven business directories, one run, one table, one schema. Type a trade and a
city, pick the directories you care about, and get company records with phone
numbers, websites, e-mail addresses, postal addresses, coordinates, star
ratings, BBB letter grades, accreditation status, staff counts and founding
years — all in the same 38 columns, no matter which directory a row came from.

Everything below is measured. Every number in this file comes from a run you
can repeat, and `tools/verify_readme.py` fails the build if a number here stops
matching the code or the measurement file.

#### What it covers

| Directory | Countries | Rows per page | What it is good for |
|---|---|---|---|
| **Yellow Pages US** (yellowpages.com) | United States | 30 | websites, years in business, star ratings |
| **Yellow Pages Canada** (yellowpages.ca) | Canada | 35 | websites, star ratings, parsed postal addresses |
| **Better Business Bureau** (bbb.org) | United States, Canada | 15 | A+ letter grades, accreditation, coordinates |
| **Gelbe Seiten** (gelbeseiten.de) | Germany | 50 then 10 | **e-mail addresses**, websites |
| **PagineGialle** (paginegialle.it) | Italy | 25 | ratings, opening hours, multiple phone numbers |
| **Europages** (europages.co.uk) | 28 European countries | 30 | B2B suppliers, staff count, founding year |
| **Hotfrog** (27 country sites) | US, GB, CA, AU, DE, FR, IT, ES, NL and 18 more | ~12 | latitude and longitude, long descriptions |

All seven answer plain HTTP from the Apify cloud. No proxy, no browser, no API
key. The actor is pure Python standard library.

#### The headline: breaking the Better Business Bureau result cap

BBB is the most valuable directory in this set — it is the one with letter
grades, accreditation and complaint history behind it — and it is also the one
that hides most of what it finds.

Ask it for plumbers in Austin, Texas and it answers, in its own JSON:
`totalResults: 2537`. Then it sets `totalPages: 15` and serves 15 rows a page.
Fifteen times fifteen is 225, and the pages repeat each other, so crawling
every page a human could ever click gives **175 unique businesses**. That is
6.9% of what the directory just told you it has. No amount of paging changes
it: page 16 returns `record-not-found`.

The way past it is a split the directory itself hands you. Next to every result
list BBB returns its own category list under `filters.byId.filter_category`, it
understands a distance ring in `filter_distance`, and it understands a sort
order in `sort`. Each of those is a separate query with its own cap of 225.
Running the same search once per bucket and merging on the business id gives,
on the same city and the same word:

| What was crawled | Unique businesses |
|---|---|
| Plain search, all 15 pages | **175** |
| + 10 category buckets BBB itself lists | 366 |
| + distance rings of 5, 10 and 15 miles | 517 |
| + sorted by distance | **524** |

**524 against 175 — three times as many**, for 21 extra queries. Both numbers
come from cloud runs of this actor with the same input and `expandBuckets`
flipped: run `kt2dccRUXf2Itm2BL` returned 175 rows, run `rKU71p8Dxq62dCgQ0`
returned 524.

Two honest notes about that. First, 524 is not 2537: the buckets overlap and
some businesses sit in no narrow category at all, so this lifts the reachable
ceiling threefold, it does not empty the directory. Second, the buckets that
earn nothing are not requested at all — rings of 25, 50, 100 and 200 miles and
the sort orders `TOB`, `Alphabetical` and `HighestRating` each added **exactly
zero** new businesses in the measurement, so asking for them would be 60
wasted requests against the source and 60 wasted seconds of your run.

#### Why seven directories in one actor matters

The Apify store sells these one at a time. To cover what this single actor
covers you would currently run `trudax/yellow-pages-us-scraper` (United States,
$5.00 a month flat), a separate Yellow Pages Canada actor,
`easyapi/bbb-business-directory-scraper` ($19.99 a month flat),
`plowdata/gelbe-seiten-ppr` for Germany, an Italian one, and
`easyapi/europages-business-directory-scraper` ($19.99 a month flat) — five or
six subscriptions, five or six different output shapes, and a merge job of your
own before any of it reaches a CRM.

Here it is one run, one dataset and one set of column names. `source` and
`source_name` on every row say where it came from; everything else means the
same thing in every row.

#### Fields, and how often they are actually filled

Thirty-eight columns. The leading Yellow Pages actor on the store documents ten
(`isAd`, `url`, `name`, `address`, `phone`, `rating`, `ratingCount`,
`infoSnippet`, `image`, `categories`). The extra columns here are not padding —
they are parsed address components, coordinates, contact channels and company
facts that the search pages really carry.

An empty cell means the directory did not publish that fact. Nothing is
inferred, guessed or filled in from another source. Measured on the cloud runs
listed in `tools/measurements.json`:

| Directory | rows | phone | website | e-mail | address | city | lat/lng | categories | rating |
|---|---|---|---|---|---|---|---|---|---|
| Yellow Pages US | 150 | 100% | 79% | — | 99% | 99% | — | 100% | 13% |
| Yellow Pages Canada | 150 | 100% | 49% | — | 53% | 51% | — | 99% | 17% |
| BBB | 524 | 99% | — | — | 100% | 100% | 100% | 100% | — |
| Gelbe Seiten | 132 | 88% | 56% | **35%** | 100% | — | — | 100% | — |
| PagineGialle | 150 | 67% | — | — | 100% | 99% | — | 100% | 26% |
| Europages | 150 | — | — | — | 100% | 100% | — | 48% | — |
| Hotfrog | 120 | 98% | — | — | 71% | 71% | 63% | 62% | — |

Directory-specific columns on top of that: `years_in_business` (Yellow Pages
US, 25% of rows), `bbb_rating` and `bbb_score` (BBB, 82%), `accredited` (BBB,
62%), `employee_count` (Europages, 38%), `founded_year` (Europages, 51%),
`service_areas` (BBB service counties and Europages supplier types),
`description` (Europages 89%, Hotfrog 100%),
`opening_hours` (Yellow Pages US 67%, PagineGialle 29%, Gelbe Seiten 27%).

#### E-mail addresses, which the others do not return

Gelbe Seiten is the only directory in this set that puts a real e-mail address
on the search page — and it does not put it anywhere a naive scraper would
look. It sits inside the JSON configuration of the page's chat widget,
alongside a cleanly split street, postcode, city and phone list. Measured per
page: **16 e-mail addresses on 50 cards** for `klempner` in Berlin and **32 on
50** for `zahnarzt` in Berlin; across a 132-row run, 35%.

The same directory hides two more things in plain sight. The website address is
base64 in a `data-webseiteLink` attribute, and the phone number of a listing
without the chat widget is base64 in `data-prg`. Both are decoded here, which
is why phone coverage is 88% rather than the 35% you get from the widget alone.

#### Getting past the Gelbe Seiten fifty-row wall

Gelbe Seiten shows 50 businesses and then a "Mehr Anzeigen" button. The button
is not a link — it posts a form to `/ajaxsuche`, and the answer is not HTML but
JSON shaped `{"anzahlTreffer":10,"html":"<escaped markup>"}`. A scraper that
parses the raw body finds zero cards in it, stops at the first page and reports
success. That is exactly what this actor did before the fix: **48 rows** while
the source itself printed `210 Treffer` at the top of the page. Unwrapping the
JSON first takes the same query to **132 rows**. The answer always carries ten
cards no matter what `anzahl` asks for — measured at 50, 10 and 50 again.

#### Directories that are not here, and why

This is the part most listings leave out. Nine directories were tested from the
Apify cloud and rejected, each for a named reason:

- **yell.com** (United Kingdom) — HTTP 403.
- **pagesjaunes.fr** (France) — HTTP 403.
- **thomasnet.com** (US industrial) — HTTP 403.
- **manta.com** — HTTP 403, Cloudflare interstitial.
- **cylex.us.com** — HTTP 403.
- **goldenpages.ie** (Ireland) — HTTP 202 with an AWS WAF challenge.
- **yellowpages.com.sg** (Singapore) — redirected to a Radware captcha.
- **BBB business profile pages** — HTTP 403. The BBB *search* API is open, the
  per-company profile page is not, which is why this actor reads everything
  from search results and never promises profile-only fields.
- **yellowpages.com.au** (Australia) — and this one is worth spelling out. It
  answers HTTP 200 and returns thirty perfectly formed listings. It also
  returns *the same thirty* for `plumber` in Sydney and for `dentist` in
  Melbourne: a list of data-processing firms that has nothing to do with either
  query. A scraper that trusts the status code would sell you that as
  Australian plumbers. It is excluded on purpose.

#### Two more silent traps, handled

**Hotfrog ignores the city.** `/search/us/plumber/austin` answers 200 and
quietly redirects to `/search/us/plumber` — the country-wide list, city
dropped. Same for `?page=2` and for `/austin/2`. And `/search/us/texas/plumber`
lands on `/search/us/texas`, losing the search word instead. So this actor
never puts a place in a Hotfrog URL: Hotfrog searches a whole country, and the
input form says so.

**PagineGialle has no website links.** Not "rarely" — zero outbound website
links on 25 cards. The buttons on an Italian listing are phone, WhatsApp,
request-a-quote and contact-us. `website` is therefore always null for Italian
rows, and the `requireWebsite` filter refuses a PagineGialle-only run before it
makes a single request rather than returning an empty dataset.

#### Empty results always say why

A successful run with an empty dataset is the worst failure a paid scraper can
have: it looks fine, you are charged for the start, and nobody can tell whether
the source broke or the input was impossible. Every empty run here writes a
named reason into the run report and a warning into the log. There are twelve of
them:

`no-search-terms`, `no-sources-selected`,
`email-not-published-by-selected-sources`, `accreditation-only-in-bbb`,
`website-not-published-by-selected-sources`,
`rating-not-published-by-selected-sources`,
`years-in-business-only-in-yellowpages-us`, `filtered-out`,
`nothing-changed`, `nothing-collected`, `source-returned-nothing`, and
`charge-limit-reached`.

The first seven are decided **before a single network request**. If you ask for
BBB accreditation but do not select BBB, or for e-mail addresses without Gelbe
Seiten, the answer was already fixed by your input: the run says so in one line
and stops, instead of downloading megabytes and throwing all of it away.

#### Filters run before you are charged

Every filter — phone present, website present, e-mail present, no paid
placements, BBB-accredited only, minimum star rating, minimum years in
business, name contains, name excludes, city contains, category contains — is
applied to a row **before** it is written to the dataset. A row a filter
removes is never billed, and the run report lists exactly how many rows each
filter removed.

Two rules are wired into those filters, and both were bought with empty
datasets on other actors. First, `maxItems` limits **rows you receive**, not
pages crawled: with a filter on, the crawl keeps going past the limit until
enough rows survive, because stopping at *n* collected rows and then filtering
them all away is how a green run delivers zero. Second, a missing value is not
a mismatch: a row where the directory printed no city passes `cityContains`,
while a row with no phone number fails `requirePhone` — because there the empty
value *is* the answer.

#### Monitoring the same search day after day

Turn on **Only new and changed businesses** and the actor remembers what it has
already delivered in a named key-value store that survives between runs. The
next run returns only rows that are new or whose name, phone, website, address
or rating changed, each tagged `change_type` — `NEW` or `UPDATED`. A daily
monitor over a 500-row search therefore pays for a handful of rows a day
instead of 500.

When nothing changed the dataset is legitimately empty, and the run says
`nothing-changed` rather than staying silent, so a watchdog can tell that apart
from a breakage.

#### Cost and speed

The actor runs on 1024 MB and only ever makes plain HTTP requests: no browser,
no proxy fees. Measured on the cloud: a 150-row Yellow Pages US run takes five
page fetches; a 524-row BBB bucket run takes 188 page fetches and finished well
inside a 900-second timeout. The default input — plumber, Austin TX, Yellow
Pages US plus BBB, 100 rows — completes in well under the five-minute window
Apify uses for its daily health check.

#### Limitations, named honestly

- **No company profile pages.** Everything comes from search result pages. The
  BBB profile page is HTTP 403 from the Apify cloud, so complaint counts,
  review text and years-in-business-per-BBB are not available at any price.
- **No start URLs.** The leading Yellow Pages actor accepts a list of search
  URLs as an alternative to a search term; this one takes search terms,
  locations, countries and directory names only.
- **No review text.** Ratings and review counts yes, individual reviews no.
- **No Australia, United Kingdom, France, Ireland or Singapore yellow pages** —
  every one of those is blocked or, in the Australian case, lying. See above.
- **Europages has no location input.** It searches by product and country, so
  the `locations` field is ignored for it.
- **Hotfrog is country-wide**, not city-wide, for the reason given above.
- **No website column for BBB, PagineGialle, Europages or Hotfrog**, and no
  e-mail column outside Gelbe Seiten: those directories do not publish them on
  the search page.
- **No proxy support.** It is not needed today — all seven answer the Apify
  cloud directly — but if one of them starts blocking datacentre addresses,
  that directory will fail rather than fall back.

#### FAQ

**Can I run several trades and several cities in one go?**
Yes. `searchTerms` and `locations` are both lists, and the actor runs every
combination of the two against every selected directory.

**Which directory should I start with?**
For United States lead lists, Yellow Pages US for contact details and BBB for
quality signals — that pair is the default input. For Germany, Gelbe Seiten,
because it is the only one with e-mail addresses. For European B2B suppliers,
Europages.

**Why is the `website` column empty for so many BBB rows?**
Because BBB does not print a website on its search results at all. It is empty
for every BBB row, not for some of them, and the same is true of PagineGialle,
Europages and Hotfrog. The table above says which directory publishes what.

**Does `expandBuckets` cost more?**
It makes more requests to BBB — 188 page fetches instead of 15 in the measured
run — and it returns three times as many businesses. Turn it off if you only
want the first 175 and want the run to finish in seconds.

**What happens if I ask for more rows than exist?**
The run stops when the directories stop returning new businesses and reports
how many it found. You are charged for the rows you receive.

**Do repeated runs return the same rows twice?**
Only if you want them to. With **Only new and changed businesses** off, every
run returns the full result set; with it on, you get new and changed rows only.

**Is a row that appears in two directories returned twice?**
Yes, once per directory, each with that directory's own fields and its own
`source`. They are deliberately not merged: a BBB record and a Yellow Pages
record for the same firm carry different facts, and silently collapsing them
would throw half of those facts away. Deduplicate on phone number or website if
you want one row per company.

**Can I get only the contact columns?**
Turn on **Compact rows** for eleven contact-only columns, or **Drop empty
fields** to leave out whatever is null on a given row. Both are meant for AI
agents and for CSV exports where 38 columns are too many.

**Does it need a proxy or an API key?**
No. Neither.

#### What else is this called?

Names buyers use: Yellow Pages scraper, BBB scraper, BBB accreditation data, Gelbe Seiten scraper, Pagine Gialle scraper, Europages scraper, Hotfrog scraper, business directory leads. Seven directories, one run, and the BBB rows carry the accreditation flag and the rating.

Also asked for as directory bucket expansion, which is the `expandBuckets` input and the `bucket` field.

# Actor input Schema

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

Trade, category or company name, exactly as you would type it into the directory. One run can take several terms.

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

City, state or postcode. Used by Yellow Pages US, Yellow Pages Canada, BBB, Gelbe Seiten, PagineGialle and Hotfrog. Europages searches by country instead, so it ignores this field.

## `sources` (type: `array`):

Every directory here answers plain HTTP from the Apify cloud — no proxy, no browser. Rows from all of them arrive in one table with one schema.

## `countries` (type: `array`):

Only these three directories take a country. Yellow Pages US, Yellow Pages Canada, Gelbe Seiten and PagineGialle are tied to their own country and ignore this field.

## `maxItems` (type: `integer`):

This is a limit on ROWS YOU GET, not on pages crawled. With filters on, the actor keeps crawling past the limit until enough rows survive the filters. Leave empty for a 100-row trial run.

## `expandBuckets` (type: `boolean`):

BBB reports thousands of matches but never returns more than 15 pages of 15 rows for one query. With this on, the actor re-runs the search once per category bucket that BBB itself lists, which reaches businesses the plain search never shows. Measured on Austin, TX / plumber: the source reported 2537 matches and capped the plain search at 225.

## `maxPagesPerQuery` (type: `integer`):

Optional safety stop. Leave empty and the actor asks for exactly as many pages as your row limit needs.

## `requirePhone` (type: `boolean`):

Applied BEFORE the row is written to the dataset, so filtered-out rows are never charged.

## `requireWebsite` (type: `boolean`):

Yellow Pages US (79% of rows), Yellow Pages Canada (49%) and Gelbe Seiten (56%) publish the website on the search page. BBB, PagineGialle, Europages and Hotfrog do not publish it at all, and selecting this filter without one of the first three stops the run before any request.

## `requireEmail` (type: `boolean`):

Gelbe Seiten is the only supported directory that puts an e-mail on the search page. Measured: 16 of 50 rows for plumber/Berlin, 32 of 50 for dentist/Berlin.

## `excludeAds` (type: `boolean`):

Removes listings the directory marks as advertising.

## `onlyAccredited` (type: `boolean`):

Better Business Bureau field. Selecting it without BBB in the directory list stops the run before any request, because it could only return zero rows.

## `minRating` (type: `integer`):

Yellow Pages US, Yellow Pages Canada and PagineGialle publish star ratings.

## `minYearsInBusiness` (type: `integer`):

Yellow Pages US field.

## `nameContains` (type: `array`):

Any one of these words is enough.

## `excludeNames` (type: `array`):

Any row whose company name contains one of these words is dropped before billing.

## `cityContains` (type: `string`):

Rows where the directory did not print a city are kept: a missing city is the directory staying silent, not a mismatch.

## `categoryContains` (type: `string`):

Keeps only rows whose category list contains this word. Rows where the directory printed no category are kept.

## `onlyNew` (type: `boolean`):

Remembers what earlier runs already delivered in a named key-value store and skips it, so a daily monitor does not pay twice for the same row. Every row carries change\_type: NEW or UPDATED.

## `emitUnchanged` (type: `boolean`):

Only meaningful together with the option above. Off by default, because unchanged rows would be charged again.

## `compactOutput` (type: `boolean`):

Eleven contact-only columns instead of thirty-seven. Useful for AI agents and for CSV exports.

## `excludeEmptyFields` (type: `boolean`):

Leaves out columns that are null for a given row.

## Actor input object example

```json
{
  "searchTerms": [
    "plumber"
  ],
  "locations": [
    "Austin, TX"
  ],
  "sources": [
    "yellowpages_us",
    "bbb"
  ],
  "countries": [
    "US"
  ],
  "maxItems": 100,
  "expandBuckets": true,
  "requirePhone": false,
  "requireWebsite": false,
  "requireEmail": false,
  "excludeAds": false,
  "onlyAccredited": false,
  "onlyNew": false,
  "emitUnchanged": false,
  "compactOutput": false,
  "excludeEmptyFields": false
}
```

# Actor output Schema

## `results` (type: `string`):

All collected rows

# 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": [
        "plumber"
    ],
    "locations": [
        "Austin, TX"
    ],
    "sources": [
        "yellowpages_us",
        "bbb"
    ],
    "countries": [
        "US"
    ],
    "maxItems": 100
};

// Run the Actor and wait for it to finish
const run = await client.actor("snow_leo_data/yellow-pages-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": ["plumber"],
    "locations": ["Austin, TX"],
    "sources": [
        "yellowpages_us",
        "bbb",
    ],
    "countries": ["US"],
    "maxItems": 100,
}

# Run the Actor and wait for it to finish
run = client.actor("snow_leo_data/yellow-pages-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": [
    "plumber"
  ],
  "locations": [
    "Austin, TX"
  ],
  "sources": [
    "yellowpages_us",
    "bbb"
  ],
  "countries": [
    "US"
  ],
  "maxItems": 100
}' |
apify call snow_leo_data/yellow-pages-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,snow_leo_data/yellow-pages-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/Ucudnj8FTxM7MbfpU/builds/k8gw4qb2RhhHiN3YU/openapi.json
