# Lead Enricher for Google Maps (`mapwise_data/lead-enricher`) Actor

Enrich Google Maps leads with emails, phones, social profiles, decision makers and tech stack from each business website — plus a 0–100 lead score that explains why each lead is worth calling.

- **URL**: https://apify.com/mapwise\_data/lead-enricher.md
- **Developed by:** [Mapwise Data](https://apify.com/mapwise_data) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $5.00 / 1,000 enriched results

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

**Turn a raw Google Maps export into a ranked call list — emails, phones, socials, decision makers, tech stack and a 0–100 lead quality score for every business.**

A Google Maps scraper gives you names, ratings and websites. It does not tell you *who* to email, *what* their site runs on, or *which* of the 800 rows is worth your morning. Lead Enricher for Google Maps crawls each business website over plain HTTP, pulls out everything a salesperson needs, and scores each lead so the best opportunities float to the top.

No headless browser, no SMTP probing, no guesswork: just fast Cheerio parsing and a transparent scoring model that tells you exactly why a lead scored what it did.

### What you get

One dataset row per business. Here is a real-shaped record for a dental practice whose site is stuck on Wix and has no booking tool:

```json
{
    "id": "b17",
    "name": "Riverside Dental Practice",
    "website": "riversidedental.co.uk",
    "normalizedWebsite": "https://riversidedental.co.uk",
    "phone": "+44 161 496 0000",
    "phones": ["+441614960000", "+441614960011"],
    "emails": [
        {
            "address": "info@riversidedental.co.uk",
            "type": "role",
            "mxValid": true,
            "sourceUrl": "https://riversidedental.co.uk/contact"
        },
        {
            "address": "j.okafor@riversidedental.co.uk",
            "type": "personal",
            "mxValid": true,
            "sourceUrl": "https://riversidedental.co.uk/about"
        }
    ],
    "socials": {
        "linkedin": "https://www.linkedin.com/company/riverside-dental",
        "instagram": "https://www.instagram.com/riversidedental",
        "facebook": "https://www.facebook.com/riversidedental",
        "x": null,
        "tiktok": null,
        "youtube": null
    },
    "people": [
        {
            "name": "Joy Okafor",
            "title": "Founder & Principal Dentist",
            "linkedin": "https://www.linkedin.com/in/joy-okafor",
            "sourceUrl": "https://riversidedental.co.uk/about"
        },
        {
            "name": "Mark Reilly",
            "title": "Practice Manager",
            "linkedin": null,
            "sourceUrl": "https://riversidedental.co.uk/about"
        }
    ],
    "techStack": {
        "cms": "Wix",
        "ecommerce": null,
        "analytics": [],
        "chat": [],
        "booking": [],
        "payments": ["Stripe"],
        "other": ["Cloudflare"]
    },
    "siteStatus": "live",
    "responseMs": 412,
    "pagesCrawled": 4,
    "leadQualityScore": 95,
    "scoreReasons": [
        "Low-end site builder",
        "Low rating — reputation risk",
        "No online booking",
        "No live chat",
        "No analytics installed",
        "Direct contact found",
        "Decision maker identified"
    ],
    "bestFor": ["web-design", "seo", "reputation", "booking-software", "chat-software", "general-outreach"],
    "daysSinceLastReview": 34,
    "input": {
        "rating": 3.2,
        "reviewsCount": 48,
        "categoryName": "Dentist",
        "placeUrl": "https://www.google.com/maps/place/Riverside+Dental+Practice"
    },
    "enrichedAt": "2026-09-22T09:14:02.118Z"
}
```

You can download the dataset in various formats such as JSON, HTML, CSV, or Excel, or pull it straight from the Apify API.

### Use cases

**Web design and development agencies.** Sort by `leadQualityScore` and filter on `bestFor: "web-design"` to get every local business with no website, a dead domain, a parked domain, or a Wix/Weebly/GoDaddy site — with a named decision maker and a verified email attached.

**SEO agencies.** `techStack.analytics` being empty is a blunt, honest signal that nobody is measuring anything. Combine it with `siteStatus` and `responseMs` for a pitch that writes itself.

**Reputation management.** Filter on `bestFor: "reputation"`: businesses rated under 3.5 with enough reviews for the rating to be real.

**Booking and scheduling software.** Restaurants, salons, hairdressers, barbers, clinics, dentists, gyms and spas with nothing in `techStack.booking` are a category-matched, no-argument pitch.

**Live chat and conversational tools.** Empty `techStack.chat` on a live site, with contact details already in hand.

**Sales teams qualifying a Google Maps export.** Run your scrape, pipe the dataset through this Actor, and hand your SDRs a list that is already ranked, deduplicated by contact, and annotated with *why* each row is on it.

### How to use Lead Enricher for Google Maps

**1. Get your businesses.** Run a Google Maps scraper such as [compass/crawler-google-places](https://apify.com/compass/crawler-google-places) and copy the **dataset ID** from the run's Storage tab. Alternatively, paste business records straight into the `businesses` input field.

**2. Point this Actor at them.** Put the dataset ID into `datasetId`, pick which enrichment steps you want, and click **Start**. Field names from the common Google Maps scrapers (`title`, `website`, `totalScore`, `reviewsCount`, `categoryName`, `url`) are mapped automatically, so no reshaping is needed.

**3. Chain it so it runs by itself.** On your Google Maps scraper's run, open **Integrations → Connect Actor** and select Lead Enricher for Google Maps. Set the input field `datasetId` to the template variable `{{resource.defaultDatasetId}}`. Every scrape then enriches itself, and you can schedule the pair to refresh a territory weekly.

Using the API directly? Start a run with the previous run's dataset ID:

```bash
curl -X POST "https://api.apify.com/v2/acts/mapwise_data~lead-enricher/runs?token=$APIFY_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{ "datasetId": "PREVIOUS_RUN_DATASET_ID", "maxPagesPerSite": 5 }'
```

### Input

| Field                | Type    | Default                     | What it does                                                                                                                |
| -------------------- | ------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `datasetId`          | string  | —                           | Dataset of a Google Maps scraper run. Every item is treated as one business. When set, `businesses` is ignored.             |
| `businesses`         | array   | one sample business         | Business records pasted directly: `{ name, website, phone, rating, reviewsCount, categoryName, placeUrl, lastReviewDate }`. |
| `enrich`             | array   | all                         | Which steps to run: `contacts`, `social`, `people`, `techStack`, `score`. Fewer steps, faster runs.                         |
| `verifyEmails`       | boolean | `true`                      | Check MX records for each email domain.                                                                                     |
| `maxPagesPerSite`    | integer | `5`                         | Pages per website including the homepage (1–15).                                                                            |
| `maxConcurrency`     | integer | `10`                        | Parallel page fetches. Lower it to be gentler on target sites.                                                              |
| `proxyConfiguration` | object  | `{ "useApifyProxy": true }` | Proxy settings. Apify Proxy is recommended.                                                                                 |

Provide **either** `datasetId` **or** `businesses`. If you provide a `datasetId`, the `businesses` field is ignored. A run with no input at all enriches a single sample business, so you can see the output format before connecting your own data.

### Output field reference

| Field                           | Type           | Description                                                                                                                     |
| ------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `id`                            | string         | Stable ID for the business within the run.                                                                                      |
| `name`                          | string         | Business name, from `name` or `title`.                                                                                          |
| `website` / `normalizedWebsite` | string | null | The website as given, and after adding a scheme, lowercasing the host and stripping tracking parameters.                        |
| `phone`                         | string | null | The phone number from the source record.                                                                                        |
| `phones`                        | string\[]       | Numbers found on the website, in E.164 where the country is inferable.                                                          |
| `emails`                        | object\[]       | `{ address, type, mxValid, sourceUrl }`. `type` is `role` (info@, hello@, sales@, …) or `personal`.                             |
| `socials`                       | object         | `linkedin`, `instagram`, `facebook`, `x`, `tiktok`, `youtube` — canonical company profile URLs, share/intent links ignored.     |
| `people`                        | object\[]       | Up to 10 `{ name, title, linkedin, sourceUrl }`, decision makers first.                                                         |
| `techStack`                     | object         | `{ cms, ecommerce, analytics[], chat[], booking[], payments[], other[] }`. `cms` falls back to `"custom"` when nothing matches. |
| `siteStatus`                    | string         | `live`, `dead`, `ssl_error`, `parked`, `blocked` or `no_website`.                                                               |
| `responseMs`                    | number | null | Homepage response time.                                                                                                         |
| `pagesCrawled`                  | number         | How many pages were actually parsed.                                                                                            |
| `leadQualityScore`              | number | null | 0–100. `null` when `score` is not in `enrich`.                                                                                  |
| `scoreReasons`                  | string\[]       | One human-readable line per scoring rule that fired.                                                                            |
| `bestFor`                       | string\[]       | `web-design`, `seo`, `reputation`, `booking-software`, `chat-software`, `general-outreach`.                                     |
| `daysSinceLastReview`           | number | null | Age of `lastReviewDate`, when the source provided one.                                                                          |
| `input`                         | object         | The `rating`, `reviewsCount`, `categoryName` and `placeUrl` we were given.                                                      |
| `enrichedAt`                    | string         | ISO timestamp of enrichment.                                                                                                    |

### Scoring explained

Every lead starts at **50** and each rule that fires moves it, appending its reason to `scoreReasons`. The final score is clamped to 0–100.

| Rule                                             | Points  | Reason                                |
| ------------------------------------------------ | ------- | ------------------------------------- |
| No website at all                                | **+20** | `No website — web design opportunity` |
| Site is dead, parked or has a broken certificate | **+25** | `Website down or parked`              |
| CMS is Wix, Weebly or GoDaddy                    | **+10** | `Low-end site builder`                |
| Rating below 3.5 with 5+ reviews                 | **+15** | `Low rating — reputation risk`        |
| No booking tool, in a booking-heavy category     | **+10** | `No online booking`                   |
| No live chat widget                              | **+5**  | `No live chat`                        |
| No analytics installed                           | **+5**  | `No analytics installed`              |
| Last review older than 180 days                  | **−10** | `Possibly inactive`                   |
| A personal email that passes the MX check        | **+10** | `Direct contact found`                |
| Someone with a decision-maker title              | **+10** | `Decision maker identified`           |
| No email, no phone and no socials                | **−20** | `Unreachable`                         |

Two deliberate design choices are worth knowing:

- Rules about what a website *lacks* (low-end builder, no chat, no analytics) only fire when at least one page was actually fetched. If a site was blocked or dead, we do not pretend to know what it runs.
- "No online booking" also fires for businesses with no website at all, because that is still a real gap in a booking-heavy category.

Booking-heavy categories are matched on `categoryName` against: restaurant, salon, hairdresser, barber, beauty, nail, spa, massage, clinic, dentist, physiotherapy and other therapy, vet, gym, fitness, yoga and pilates.

### Pricing

This Actor uses **pay per event**, so you pay for results rather than for time:

| Event            | When it is charged                                     |
| ---------------- | ------------------------------------------------------ |
| `lead_enriched`  | Once per business successfully written to the dataset. |
| `email_verified` | Once per email address that went through an MX check.  |

Businesses that fail because of an error on our side are not charged. **Your spending limit is respected:** if you set a maximum cost per run, the Actor only enriches as many businesses as that budget covers, and the run's status message tells you how many that was. Set `verifyEmails: false` to skip MX verification entirely and avoid the second event. Because the crawl is HTTP-only, compute cost is low: a run over 1,000 businesses at `maxPagesPerSite: 5` is a few thousand small HTTP requests, not a few thousand browser sessions.

### Tips

- **Start with `maxPagesPerSite: 3`** on a large list. The homepage plus a contact and an about page finds most contact details; pages 4 and 5 have diminishing returns.
- **Trim `enrich`** if you only need one thing. Running just `["contacts", "score"]` is meaningfully faster than the full set.
- **Use Apify Proxy** for lists over a few hundred businesses, otherwise shared hosting providers start rate-limiting you and more sites come back `blocked`.
- **Lower `maxConcurrency`** if you are enriching many businesses that share one hosting provider.

### FAQ

**Does it use a headless browser?** No. Everything is plain HTTP with Cheerio parsing, which is roughly an order of magnitude cheaper and faster than a browser crawl. The trade-off is that contact details rendered purely by client-side JavaScript are not visible — in practice, contact and team pages are overwhelmingly server-rendered.

**Does it verify emails over SMTP?** No. We resolve MX records for each email domain and cache the result per domain. No SMTP handshake is opened and no mail is ever sent, so nothing is delivered to the addresses and nothing shows up in anyone's inbox. `mxValid: true` means the domain can receive mail; it does not prove that specific mailbox exists.

**What about sites that block crawlers?** They are classified, not dropped. A site that refuses us gets `siteStatus: "blocked"`, and the business is still scored on everything we do know — rating, reviews, category, source phone. Failed DNS or connection gives `dead`, a certificate problem gives `ssl_error`, and a domain-parking page gives `parked`.

**What if a business lists a booking page as its website?** Many salons and clinics list a Fresha, Treatwell, Booksy or similar page instead of their own site. Those are recognized: the business gets `siteStatus: "no_website"` (it has no site of its own, which makes it a web-design lead) and the platform is recorded in `techStack.booking`, so it is not also flagged for missing online booking. NHS, Yell and similar directory listings are treated as no website too.

**Which Google Maps scrapers work as a source?** Any of them. Field names from `compass/crawler-google-places` (`title`, `totalScore`, `reviewsCount`, `categoryName`, `url`) and common variants are mapped automatically; anything unrecognised falls back to `name` and `website`.

**Is scraping business websites legal?** This Actor reads publicly available pages, respects a one-second delay per domain, and collects business contact information — the kind of details a company publishes precisely so it can be contacted. You are responsible for how you use the output, including compliance with GDPR, CAN-SPAM, PECR and the terms of the sites involved. Do not use it to collect personal data you have no lawful basis to process.

**Something is wrong or missing.** Open an issue on the Actor's **Issues** tab. Custom fields, extra tech-stack signatures and different scoring weights are all straightforward to add.

### Changelog

#### 0.1.2 — fixes from a pre-launch test on real businesses

Tested end to end on real dentists and hair salons, chained after the Google Maps Scraper exactly as described above.

- Chaining after a Google Maps Scraper run now works. The Actor previously lacked permission to read the scraper's dataset.
- Your maximum cost per run is respected: only as many businesses as the budget covers are enriched.
- A run with no input enriches one sample business instead of failing.
- Businesses whose "website" is a Fresha, Treatwell, Booksy or similar booking page are treated as having no website of their own, with the platform recorded as their booking tool. Directory listings such as NHS and Yell are treated the same way.
- Service names, policies and page sections ("Smile Makeover", "Privacy Policy") are no longer reported as people. Job titles are matched as whole words.
- "Principal" is recognized as a decision-maker title.
- Street numbers are no longer attached to the end of phone numbers.
- Cloudflare-protected email addresses are now decoded; masked placeholders are rejected.
- Department inboxes (directors@, press@, reception@) and role words joined to a business name (infostudioseven@) are classified as shared, not personal.
- Hairdressers, beauty, nails, physio, fitness and similar appointment-based categories now count as booking-heavy.

#### 0.1.1 — accuracy fixes from the first real runs

- Phone numbers are no longer read out of a site's embedded JavaScript data. Modern sites (Next.js and similar) previously produced dozens of fake numbers per page.
- Company registration and VAT numbers are no longer reported as phone numbers.
- Email addresses no longer run into the text of the link next to them.
- Magento is only reported for sites that actually run it, not for pages that mention it or contain images.
- UK and European numbers normalize correctly to E.164, including `+44 (0)…` and numbers already written with their country code.
- Opening hours and copyright year ranges are no longer mistaken for phone numbers.
- Link labels such as "Connect with Jane" are no longer reported as people.

#### 0.1 — initial release

- Enrichment from a Google Maps dataset ID or inline business records.
- Contact extraction (emails with role/personal classification and de-obfuscation, phones in E.164).
- Company social profiles and personal LinkedIn discovery.
- People and job titles from About/Team pages.
- Tech-stack detection across CMS, e-commerce, analytics, chat, booking and payments.
- Site status classification: live, dead, SSL error, parked, blocked, no website.
- MX-based email verification with per-domain caching.
- 0–100 lead quality score with reasons and `bestFor` routing.
- Pay-per-event billing: `lead_enriched`, `email_verified`.

# Actor input Schema

## `datasetId` (type: `string`):

ID of a dataset produced by a Google Maps scraper (for example compass/crawler-google-places). Every item in it is treated as one business. Leave empty if you paste businesses directly below.

## `businesses` (type: `array`):

Business records to enrich, if you are not using a source dataset. Ignored when a source dataset ID is set. Each item accepts: name, website, phone, rating, reviewsCount, categoryName, placeUrl and lastReviewDate. Google Maps field names (title, totalScore, url) are accepted too.

## `enrich` (type: `array`):

Which enrichment steps to run. Fewer steps means faster, cheaper runs.

## `verifyEmails` (type: `boolean`):

Check that each email domain has MX records, so you can drop addresses that can never receive mail. No SMTP handshake is performed and no mail is sent.

## `maxPagesPerSite` (type: `integer`):

How many pages to fetch per business website, including the homepage. The extra pages are picked from contact, about and team links.

## `maxConcurrency` (type: `integer`):

How many pages to fetch in parallel. Lower it to be gentler on the websites you crawl.

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

Proxy settings. Apify Proxy is recommended so that business websites do not rate-limit the crawl.

## Actor input object example

```json
{
  "businesses": [
    {
      "name": "Apify",
      "website": "https://apify.com",
      "categoryName": "Software company"
    }
  ],
  "enrich": [
    "contacts",
    "social",
    "people",
    "techStack",
    "score"
  ],
  "verifyEmails": true,
  "maxPagesPerSite": 5,
  "maxConcurrency": 10,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `leads` (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 = {
    "businesses": [
        {
            "name": "Apify",
            "website": "https://apify.com",
            "categoryName": "Software company"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("mapwise_data/lead-enricher").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 = { "businesses": [{
            "name": "Apify",
            "website": "https://apify.com",
            "categoryName": "Software company",
        }] }

# Run the Actor and wait for it to finish
run = client.actor("mapwise_data/lead-enricher").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 '{
  "businesses": [
    {
      "name": "Apify",
      "website": "https://apify.com",
      "categoryName": "Software company"
    }
  ]
}' |
apify call mapwise_data/lead-enricher --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,mapwise_data/lead-enricher"
        }
    }
}
```

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/9hjSgxcd8gg0sOxtF/builds/LARrtcPyugwavvITs/openapi.json
