# DuckDuckGo Scraper - Local Business Data, Yelp, Apple Maps (`snow_leo_data/duckduckgo-scraper`) Actor

Local businesses Google Maps misses: 82 of 164 businesses returned here were not in Google Maps at all, measured on 10 category and city pairs. Website filled for 97.9% of rows, 44 fields per business, from Yelp and Apple Maps data. DuckDuckGo local business data API, Yelp data scraper.

- **URL**: https://apify.com/snow\_leo\_data/duckduckgo-scraper.md
- **Developed by:** [Snow Leo Data](https://apify.com/snow_leo_data) (community)
- **Categories:** Lead generation, 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 business delivereds

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

## DuckDuckGo Local Business Scraper

**Half the businesses this Actor returns are not in Google Maps results for the same search.**

That is the whole reason to run it. It reads DuckDuckGo's local business search,
which is built on **Yelp and Apple Maps** data — a different index from Google's,
with a different set of businesses in it. If you already scrape Google Maps, this
is the half of the local market you are not seeing.

- **$0.42 per 1,000 businesses.** The median price in this niche is $2.87 per
  1,000 across 178 Actors that publish a per-result price; the cheapest
  serious Yelp-sourced competitor is $0.50.
- **44 fields per business**, including price level, amenities, opening hours by
  day, and up to 5 **review excerpts with the review text itself**.
- **No API key, no login, no proxy, no browser.** Python 3.8, standard library
  only. That is why it costs what it costs.

***

### How much of this is actually different from Google Maps?

Measured 16 September 2026 on 10 `category + city` pairs — coffee and dentists
in Austin, plumbers in Denver, hair salons in Miami, auto repair in Phoenix,
pizza in Chicago, florists in Seattle, gyms in Boston, bakeries in Portland,
vets in Atlanta.

For each pair, Google Maps was swept **to its own ceiling** (94–160 businesses
per pair, far deeper than one page), and business names on both sides were
reduced to a comparable form — no case, no accents, no `the / llc / inc / co /
ltd`.

| | |
|---|---|
| Businesses returned here | 164 |
| Of those, also found in Google Maps | 82 |
| **Not in Google Maps at all** | **82 (50%)** |

Reproduce it: `python3 tools/measure.py` (the overlap script is in `recon/`).

The spread between pairs is wide, and worth knowing before you plan a run.
Florists in Seattle overlap almost completely with Google (17 of 19 were in
both); auto repair in Phoenix barely overlaps at all (2 of 18). An earlier,
smaller run over 6 pairs read 62% — the number here is from the larger sample,
because a representative measurement beats a flattering one.

There is a second, quieter difference. The **website** field — the one you need,
because that is where an email address comes from — is filled for **97.9%** of
businesses here (375 rows) against **79.7%** in a Google Maps sample of 300 rows
taken the same day.

***

### What you get per business

44 fields. Fill rates below are measured on 375 live businesses across 20
searches — ten US cities and ten cities in Europe, Canada and India.

**Always there (100%):** `name`, `address`, `country_code`, `latitude`,
`longitude`, `timezone`, `yelp_url`, `place_id`, `data_providers`.

| Field | Filled |
|---|---|
| `website`, `website_domain` | 97.9% |
| `category`, `categories` | 96.0% |
| `phone`, `phone_display` | 94.7% |
| `city` | 84.3% |
| `hours`, `hours_text` (per weekday) | 82.7% |
| `rating` | 80.0% |
| `reviews_count` | 80.0% |
| `reviews` (excerpt, stars, author, date) | 78.7% |
| `image_url`, `image_urls` | 76.8% |
| `yelp_business_id` | 49.6% |
| `neighborhood` | 42.7% |
| `price_level`, `price_text` (`$` to `$$$$`) | 32.3% |
| `amenities`, `amenity_list` | 24.8% |
| `menu_url` | 14.4% |

The last four look thin worldwide because they are restaurant and bar fields. On
a US hospitality sample (142 businesses across 8 searches — restaurants, bars,
hotels, breweries, spas) the same fields read: **review text 91.5%, amenities
74.6%, price level 57.7%, menu 55.6%**.

**Amenities** are structured booleans, not a text blob: `wifi_free`, `wifi_paid`,
`parking`, `parking_valet`, `outdoor_seating`, `wheelchair_accessible`,
`dogs_allowed`, `restaurant_takeout`, `restaurant_delivery`.

**Reviews** carry the actual text. Of the 517 businesses checked, 265 came with
3 reviews, 121 with 5, and 92 with none.

**`data_providers` tells you where each field came from** — the source labels it
itself, per business, e.g. `{"address": "yelp", "hours": "yelp", "phone":
"yelp", "url": "apple"}`. Also exposed are the Apple Maps values alongside the
Yelp ones (`apple_name`, `apple_address`, `apple_phone`), so you can see when
the two indexes disagree about the same place.

***

### The source lies quietly, and this Actor catches it

This is the single most important thing on the page.

**Lie one: a location it does not know is silently replaced by the location of
the machine that asked.** Ask for `coffee zzqqxxnotacity` and the source answers
HTTP 200, no error, no warning — with businesses near whoever sent the request.

This was measured **in the cloud**, not on a laptop, because that is where it
actually bites. From a laptop the swap is obvious: a couple of cafes with
foreign addresses. From an Apify container, the same nonsense location returned
**20 real coffee shops in Leesburg, Virginia** — a real town, real businesses,
real phone numbers, real opening hours. Nothing about that dataset looks wrong.
A typo in one city name would have handed you a clean, plausible, completely
useless lead list, and you would have paid for it.

With the check on, all 20 were dropped and the run reported
`location-not-recognised`. With it off, they are delivered as-is.

**Lie two: the location is understood and the businesses are still somewhere
else.** `bakery mexico city` is parsed correctly — the source echoes back
`where: "mexico city"` — and then returns a bakery in **Oklahoma City, USA**.

**Verify location** is on by default and handles both. Every response is checked
against what you asked for, row by row; rows that fail are dropped, counted, and
reported in the `REPORT` record as `dropped_location_not_recognised` and
`dropped_location_mismatch`, with the offending locations listed under
`unrecognised_locations`. Postcodes still work: search `78704` and the source
normalises it to `Austin, TX`, which the check accepts.

Turn it off with `verifyLocation: false` if you want the raw response.

***

### There is no page 2, so volume comes from asking more questions

One search returns about 20 businesses — 14 to 21 across the 20-search sample —
and **the source has no paging of any kind**. Nine offset parameter names
(`s`, `start`, `page`, `offset`, `max_results`, `count`, `n`, `first`) and five
coordinate/bounding-box names (`latitude`/`longitude`, `lat`/`lon`,
`is_lat`/`is_lon`, `bbox_tl`/`bbox_br`, `map_bbox`) were all accepted silently
and all returned **the same first page**. One lever exists: the search string.

Two options use that lever.

**Deep search** (`expandQueries`) repeats every search with 12 measured
prefixes — `cheap`, `top rated`, `open now`, `independent`, `famous`, `near`,
`popular`, `24 hour`, `new`, `small`, `affordable`, `downtown`. The list is not
guessed: 16 prefixes were tried and 3 dropped for returning nothing new.
Measured with the shipped code:

| Search | Queries | Plain | With deep search |
|---|---|---|---|
| coffee, Austin TX | 13 | 18 | **67** (×3.7) |
| plumber, Denver CO | 13 | 19 | **123** (×6.5) |

**Districts** (`areas`) prefix each location with a district, suburb or
postcode. Combining both levers on coffee in Austin — 58 searches over 12
districts and postcodes — returned **334 unique businesses against 19 from a
single search, a factor of 17.6**.

Both cost requests: deep search is 13× the requests. `maxQueries` caps the total
per run (default 120), and the cheapest combinations always run first, so
hitting the cap costs you depth, not cities.

***

### Monitor mode

`incremental: true` remembers what it already delivered, in a **named** key-value
store that survives between runs, and labels every row `NEW`, `UPDATED` or
`UNCHANGED`. Unchanged businesses are not written and not charged unless you ask
for them with `emitUnchanged`.

The fingerprint covers what actually changes and is worth watching: name,
category, address, phone, website, rating, review count, opening hours, price
level, `permanently_closed`. It deliberately excludes `open_now` and the other
instant flags — they change by the minute, and including them would mark half a
city as "updated" on every run and bill you for it.

***

### Filters, and what they cost

Every filter runs **before the delivery limit and before you are charged**, so
you pay for matches, not for candidates.

`onlyWithWebsite`, `onlyWithoutWebsite` (the lead list for anyone selling
websites), `onlyWithPhone`, `onlyWithReviewText`, `skipPermanentlyClosed`,
`minRating`, `minReviews`, `minPriceLevel`, `maxPriceLevel`, `categoryContains`,
`nameContains`, `websiteDomainContains` (find everyone on Squarespace or Wix),
`countryCodes`, `requiredAmenities`.

**A filter never drops a business just because the field is missing.** A business
with no rating survives `minRating`; a business whose amenities are unknown
survives `requiredAmenities`. Missing data is not a mismatch. The only
exceptions are the filters that ask about presence itself — `onlyWithWebsite`
and friends — where absence is the answer.

**`maxItems` limits what you receive, not what is crawled.** Ask for 100
businesses with a filter on and you get up to 100 matching businesses, not 100
candidates of which three survive.

***

### When the result is empty, it says why

A green run with an empty dataset is the worst thing a paid Actor can do. This
one always names the reason, in the log and in the `REPORT` record's
`empty_reason`:

`no-search-given`, `rating-filter-impossible`, `price-filter-impossible`,
`website-filter-contradicts-domain`, `reviews-filter-without-reviews`,
`charge-limit-reached`, `source-returned-nothing`, `location-not-recognised`,
`location-mismatch`, `filtered-out`, `nothing-changed`, `nothing-collected`.

The first six are decidable from your input alone, and they are checked **before
a single request is sent** — you are not billed time for a run whose result was
already determined by the form you filled in.

***

### Honest limits

Things this Actor does not do, or does worse than someone else. Named here
rather than left for you to discover.

- **No emails.** The source never carries one. It carries a website for 97.9% of
  businesses, and getting an email means crawling that website — which is a
  different job and a different Actor.
- **Up to 5 review excerpts per business, not the full review history.** The
  excerpts come from the search response; there is no paging into older reviews.
  If you need every review of one business, use a dedicated Yelp review scraper.
- **No direct business URLs.** The endpoint is a search endpoint. You can search
  for a business by name and city, but you cannot hand it a Yelp page URL.
- **No sort order.** The source returns its own relevance order and accepts no
  sort parameter.
- **Coverage is uneven outside big US cities and big categories.** In the
  20-search sample every search returned 14–21 businesses, including London,
  Berlin, Paris, Amsterdam, Toronto, Dublin, Rome, Vienna, Madrid, Manchester
  and Mumbai. But a spot check of thinner pairings returned far less:
  `plumber toronto` 0, `cafe sydney` 1, `bakery mexico city` 1, `sushi tokyo` 3,
  `gym dubai` 7. Check your pairing before planning a big run.
- **Six fields exist in the source and are always empty, so they are not
  shipped:** `popular_hours`, `facebook_id`, `instagram_id`, `twitter_id`,
  `order_url`, `embed`. Checked across 517 live businesses — not one was ever
  filled. A column that is always empty pretends to a completeness that is not
  there.
- **The source's distance fields are deliberately dropped.** `distance` is
  always 0, and `distance_to_user_meters` is the distance to *the machine that
  sent the request* — in the cloud, a datacentre. In testing it read 8,948,075
  metres from a laptop to an Austin coffee shop. That is not a number you can
  use.
- **Raw searches (`customQueries`) cannot be location-checked**, because there is
  no separate location to check against.
- **Leaving `locations` empty cannot be location-checked either**, for the same
  reason — you did not name a place, so there is nothing to compare against, and
  the source answers with whatever is nearest the container. The run says so in
  the log rather than handing you the rows quietly.

***

### Speed and cost

The source tolerated **12 back-to-back requests with no pause in 23.8 seconds —
30.2 requests per minute, zero HTTP 429s**. It throttles itself with a ~2 second
response time, so the Actor paces requests rather than hammering it. Budget
roughly 2 seconds per search: a 120-search run takes about 4–5 minutes.

Runs on 512 MB. No proxy cost, no browser, no API key.

***

### Reproducing every number on this page

Nothing here is an estimate.

```
python3 tools/measure.py ceiling   # ~20 businesses per search, 14-21 range
python3 tools/measure.py paging    # 14 parameter names, 1 distinct page
python3 tools/measure.py lies      # both silent-fallback behaviours
python3 tools/measure.py rate      # requests per minute, 429 count
python3 tools/measure.py expand    # deep search multipliers
python3 tools/measure.py fill      # field fill rates, dead fields
python3 tools/measure.py prices    # niche prices from the Apify store API
```

Results land in `tools/measurements.json`. `python3 tools/verify_readme.py`
checks every number in this file against that file, against the input schema and
against the code, and fails if any of them drift apart.

Tests: `python3 -m unittest discover tests` — 78 tests across logic without
network, live source, and run lifecycle (interruption, resume, repeat run,
memory between runs, charge limit).

#### What else is this called?

Also asked for as location verification geocoding, which is the `verifyLocation` input.

# Actor input Schema

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

What kind of business to look for: 'coffee', 'dentist', 'plumber', 'hair salon'. Every term is combined with every location below.

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

City, region or postcode - 'Austin, TX', 'London', '78704'. Leave empty and the source answers with businesses near the datacentre this Actor runs in, which is almost never what you want.

## `expandQueries` (type: `boolean`):

The source has no paging: one search returns about 20 businesses and there is no page 2. Rewording the same search surfaces different businesses, so this option repeats every search with 12 measured prefixes ('cheap', 'top rated', 'open now', 'independent' and so on). Measured with the shipped list: coffee in Austin went from 18 businesses to 67, plumbers in Denver from 19 to 123. Costs 13x the requests.

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

Counts rows you actually RECEIVE, not rows looked at. Filters are applied before this limit and before you are charged, so a limit of 100 with filters on still delivers up to 100 matching businesses. 0 means no limit.

## `areas` (type: `array`):

Optional. Each one is added in front of every location to dig deeper into the same city: 'downtown', 'south', '78704'. This is the other way past the ~20-per-search ceiling.

## `maxQueries` (type: `integer`):

A guard on your own input: 20 terms x 20 cities x deep search is 5200 searches and over two hours of compute. Cheapest combinations run first, so cutting here costs you depth, not breadth.

## `customQueries` (type: `array`):

Advanced. Sent to the source word for word, ahead of everything else, exactly as typed - 'vegan bakery Brooklyn'. The location check cannot be applied to these, because there is no separate location to check against.

## `verifyLocation` (type: `boolean`):

The source lies quietly in two ways. Give it a city it does not know and it answers HTTP 200 with businesses near the machine that sent the request - measured from an Apify container, a nonsense city returned 20 real coffee shops in Leesburg, Virginia, and nothing about that dataset looks wrong. Second, 'bakery mexico city' is understood and still returns a bakery in Oklahoma City. With this on, both kinds of row are dropped and counted in the REPORT record instead of being handed to you as yours.

## `reviewsPerBusiness` (type: `integer`):

The source carries up to 5 review excerpts per business, with star rating, author and date. Measured on 517 live businesses: 3 reviews for 265 of them, 5 for 121, none for 92. Set to 0 to leave review text out.

## `onlyWithWebsite` (type: `boolean`):

A website is carried for 97.9% of businesses in a 375-row sample - it is where you go next for an email address.

## `onlyWithoutWebsite` (type: `boolean`):

The classic lead list for anyone selling web design.

## `onlyWithPhone` (type: `boolean`):

A phone number is carried for 94.7% of businesses in a 375-row sample.

## `onlyWithReviewText` (type: `boolean`):

Needs 'Reviews per business' above 0.

## `skipPermanentlyClosed` (type: `boolean`):

Drops businesses the source marks as closed for good. A business whose status is unknown is kept.

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

Ratings run 1 to 5. A business with no rating at all is kept, not dropped.

## `minReviews` (type: `integer`):

A business with no review count at all is kept, not dropped.

## `minPriceLevel` (type: `integer`):

1 is $, 4 is $$$$. Carried for 32.3% of businesses worldwide and 57.7% of US restaurants and bars.

## `maxPriceLevel` (type: `integer`):

1 is $, 4 is $$$$. Leave at 0 for no upper bound.

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

Case-insensitive substring, matched against every category the business carries.

## `nameContains` (type: `string`):

Case-insensitive substring of the business name.

## `websiteDomainContains` (type: `string`):

Find the businesses on one platform - 'squarespace.com', 'wixsite.com'.

## `countryCodes` (type: `array`):

Two-letter codes: US, GB, DE, FR, NL, IN, AE.

## `requiredAmenities` (type: `array`):

A business whose amenities are unknown is kept, not dropped.

## `incremental` (type: `boolean`):

Remembers what it already gave you in a named key-value store and labels every row NEW, UPDATED or UNCHANGED. The fingerprint covers name, category, address, phone, website, rating, review count, opening hours, price level and closure - not 'open right now', which changes by the minute and would mark half the city as changed on every run.

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

Only matters in monitor mode. Off, an unchanged business is not written and not charged.

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

For AI agents and anyone paying for volume. Name, id, address, city and country stay even when empty, so a consumer reading by schema does not trip over a missing column.

## `requestTimeoutSecs` (type: `integer`):

How long to wait for one request. The source answers in about 2 seconds; raise this only if you see timeouts in the log.

## Actor input object example

```json
{
  "searchTerms": [
    "coffee"
  ],
  "locations": [
    "Austin, TX"
  ],
  "expandQueries": false,
  "maxItems": 100,
  "maxQueries": 120,
  "verifyLocation": true,
  "reviewsPerBusiness": 3,
  "onlyWithWebsite": false,
  "onlyWithoutWebsite": false,
  "onlyWithPhone": false,
  "onlyWithReviewText": false,
  "skipPermanentlyClosed": false,
  "incremental": false,
  "emitUnchanged": false,
  "compactOutput": false,
  "requestTimeoutSecs": 30
}
```

# 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": [
        "coffee"
    ],
    "locations": [
        "Austin, TX"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("snow_leo_data/duckduckgo-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": ["coffee"],
    "locations": ["Austin, TX"],
}

# Run the Actor and wait for it to finish
run = client.actor("snow_leo_data/duckduckgo-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": [
    "coffee"
  ],
  "locations": [
    "Austin, TX"
  ]
}' |
apify call snow_leo_data/duckduckgo-scraper --silent --output-dataset

```

## MCP server setup

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