# Google Maps Lead Finder & Business Enricher (`outspoken_strategy/google-maps-lead-finder`) Actor

Find local businesses on Google Maps and turn them into structured leads: website enrichment, public emails, phones, WhatsApp, social profiles, contact people, optional reviews with sentiment, and deterministic lead scoring. No AI or API keys required.

- **URL**: https://apify.com/outspoken\_strategy/google-maps-lead-finder.md
- **Developed by:** [code craker](https://apify.com/outspoken_strategy) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $40.00 / 1,000 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?

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

## Google Maps Lead Finder & Business Enricher

**Google Maps business discovery + website enrichment + public contact extraction + social discovery + deterministic lead scoring — in one Actor.**

This Actor searches Google Maps for local businesses (for example *"dentists in Johannesburg"*), extracts each listing, removes duplicates across all your searches, visits each business's **official website** to collect **publicly published** emails, phone numbers, WhatsApp links, social profiles and team contacts, optionally reads **public reviews** with a transparent sentiment score, and finally scores every business as a sales/marketing lead with a documented, deterministic formula.

It is more than a basic Google Maps scraper: every record is a ready-to-use lead with contact channels, online-presence signals and observable marketing gaps.

> **V1 contains no AI.** No OpenAI/Claude/Gemini, no LLM or embedding APIs, no API keys. Every value is extracted or computed deterministically, and nothing is fabricated.

***

### Contents

- [Who it is for](#who-it-is-for)
- [Key features](#key-features)
- [Use cases](#use-cases)
- [Input parameters](#input-parameters)
- [Example input](#example-input)
- [Output fields](#output-fields)
- [Example output](#example-output)
- [Lead scoring methodology](#lead-scoring-methodology)
- [Review sentiment methodology](#review-sentiment-methodology)
- [Performance](#performance)
- [Proxy configuration](#proxy-configuration)
- [Limitations](#limitations)
- [Responsible usage](#responsible-usage)
- [Troubleshooting](#troubleshooting)
- [Local development](#local-development)
- [Apify deployment](#apify-deployment)
- [Pricing considerations](#pricing-considerations)
- [Architecture](#architecture)

***

### Who it is for

- **Sales teams and SDRs** building prospect lists for a territory or vertical.
- **Marketing and web agencies** looking for businesses with gaps they can help with (no website, no Instagram, etc.).
- **Local SEO consultants** auditing a category in a city.
- **CRM/RevOps teams** enriching or refreshing local-business records.
- **Researchers and analysts** studying local markets.

### Key features

| Feature | Details |
| --- | --- |
| Google Maps discovery | Any query + location, scrolls the results feed, handles consent pages, lazy loading, single-result redirects and "no results". |
| Rich listing data | Name, categories, rating, review count, full and parsed address, coordinates, phone, website, Place ID, CID, opening hours, price level, description, services/attributes, photo, Plus Code, business status, sponsored flag. |
| Cross-query deduplication | Place ID → normalized Google Maps URL → name + address → name + phone. A business found by "dentists" and "dental clinics" appears once, with both queries in `matchedQueries`. |
| Website enrichment | Crawls the official website (homepage + prioritized contact/about/team/services/locations pages, up to `maxWebsitePages`), respects `robots.txt`. |
| Emails | `mailto:` links, visible text and structured data; validated, false positives removed (image names, tracking ids, placeholders, no-reply), classified (`general`, `contact`, `sales`, `support`, `bookings`, `marketing`, `other`). |
| Phones | Google Maps listing phone plus website `tel:` links and text, normalized to E.164 with libphonenumber. National numbers are only converted when the country is known — the country code is never guessed. |
| WhatsApp | Detects `wa.me`, `api.whatsapp.com/send`, `whatsapp://send` links published by the business. |
| Social profiles | Facebook, Instagram, LinkedIn, TikTok, YouTube, X/Twitter — only profiles explicitly linked from the official website; share buttons, posts and web-agency links are filtered out. |
| Contact people | Names and roles the business itself publishes (JSON-LD, team cards, "Name – Role" lines). A name is only accepted next to a role. |
| Reviews (optional) | Public Google Maps reviews with rating, text, relative date and reviewer display name. |
| Sentiment | Deterministic lexicon + star rating → per-review label, `overallSentimentScore` (0–100), recurring positive/negative topics. |
| Lead scoring | Transparent 0–100 score with a per-category breakdown and a HIGH/MEDIUM/LOW marketing opportunity level backed by observable facts. |
| Reliability | Retries with exponential backoff, timeouts at every stage, per-business error isolation (`errors` field), graceful CAPTCHA handling (no bypass), resumable after Apify migrations. |

### Use cases

- **Lead generation** and **sales prospecting** by category and city
- **Marketing agencies**: find businesses with no website or weak social presence
- **Local SEO** audits of a category/area
- **CRM enrichment** of existing local-business accounts
- **Market research** and competitor mapping
- **Dental lead generation** – "dentists", "orthodontists", "dental clinics"
- **Restaurant lead generation** – "restaurants", "coffee shops", "caterers"
- **Hotel lead generation** – "hotels", "guest houses", "B\&Bs"
- **Car dealership lead generation** – "car dealerships", "used car dealers"
- **Real estate lead generation** – "real estate agents", "property management"
- **Business directory building** for a region or niche

### Input parameters

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `searchQueries` | array of strings | — (required) | What to search for. Duplicates (case/whitespace-insensitive) are removed. 1–100 queries. |
| `location` | string | `null` | Appended to every query as `"<query> in <location>"`. Leave empty if queries already contain a location. |
| `maxResultsPerQuery` | integer 1–500 | `20` | Google Maps results collected per query. Google usually shows up to ~120 per search. |
| `includeReviews` | boolean | `false` | Extract public reviews and compute sentiment. |
| `maxReviewsPerBusiness` | integer 0–200 | `10` | Review limit per business. |
| `includeSocialProfiles` | boolean | `true` | Extract social profiles linked from the website. |
| `enrichWebsite` | boolean | `true` | Visit official websites. When `false`, **no website is requested**. |
| `extractEmails` | boolean | `true` | Extract emails from websites. |
| `extractPhones` | boolean | `true` | Extract extra phones and WhatsApp links from websites. The Google Maps listing phone is always included. |
| `extractContactPeople` | boolean | `true` | Extract names/roles published by the business. |
| `maxWebsitePages` | integer 1–20 | `5` | Pages per website including the homepage. |
| `maxConcurrency` | integer 1–50 | `10` | Parallel Google Maps pages. One browser per 5 pages; plan ~1 GB memory per 5 pages. |
| `proxyConfiguration` | object | `{ "useApifyProxy": true }` | Proxy for Google Maps. Default automatic (datacenter) group works and is not billed per GB; use RESIDENTIAL only if Google blocks the run. |
| `websiteProxyConfiguration` | object | `{ "useApifyProxy": false }` | Proxy for business websites only. Default none — website pages are cheap to fetch directly; use a DATACENTER group if sites block Apify IPs. |

Invalid input is rejected before any request is made, with every problem listed (e.g. `"maxConcurrency" must be at least 1.`).

### Example input

```json
{
    "searchQueries": ["dentists", "dental clinics"],
    "location": "Johannesburg, South Africa",
    "maxResultsPerQuery": 100,
    "includeReviews": true,
    "maxReviewsPerBusiness": 10,
    "includeSocialProfiles": true,
    "enrichWebsite": true,
    "extractEmails": true,
    "extractPhones": true,
    "extractContactPeople": true,
    "maxWebsitePages": 5,
    "maxConcurrency": 5,
    "proxyConfiguration": { "useApifyProxy": true }
}
```

### Output fields

Every business is **one dataset item** with the same set of keys. Unavailable scalar values are `null`, unavailable lists are `[]`.

| Group | Fields |
| --- | --- |
| Identity | `businessName`, `category`, `categories`, `placeId`, `cid`, `googleMapsUrl`, `businessStatus`, `isSponsoredResult` |
| Location | `address`, `street`, `city`, `region`, `postalCode`, `country`, `latitude`, `longitude`, `plusCode` |
| Listing | `rating`, `reviewCount`, `openingHours`, `priceLevel`, `description`, `services`, `attributes`, `imageUrl` (the listing's hero photo, when Google has loaded one), `website` |
| Contact | `phone` (E.164 when known), `phones`, `internationalPhone`, `email`, `emails[]` (`email`, `type`, `matchesWebsiteDomain`, `sources`), `whatsapp`, `whatsappNumbers` |
| Social | `socialProfiles` (`facebook`, `instagram`, `linkedin`, `tiktok`, `youtube`, `twitter`, `whatsapp`) |
| People | `contactPeople[]` (`name`, `role`, `sourceUrl`) |
| Website | `websiteData` (`title`, `description`, `pagesCrawled`, `addresses`, `additionalLocations`, `signals`: `https`, `reachable`, `hasContactPage`, `hasDescription`, `mobileViewport`, `structuredData`) |
| Reviews | `reviews[]` (`reviewId`, `rating`, `text`, `reviewDate`, `reviewerName`, `reviewUrl`, `sentiment`), `overallSentimentScore`, `topPositiveTopics`, `topNegativeTopics` |
| Scoring | `leadScore`, `leadScoreBreakdown`, `marketingOpportunity`, `marketingOpportunities` |
| Run info | `enrichmentStatus` (`website`: `success`/`failed`/`skipped`/`blocked`/`no-website`/`disabled`; `reviews`: `success`/`failed`/`skipped`/`not-available`), `errors[]` (`error`, `errorStage`), `queryUsed`, `matchedQueries`, `searchPosition`, `scrapedAt` |

A `RUN_SUMMARY` record is also saved to the key-value store (results per query, duplicates skipped, websites crawled/failed, whether Google blocked any request).

The dataset has three Console views: **Leads overview**, **Contacts & social**, and **Lead scoring**.

### Example output

An abbreviated record from a real run (`"dentists in Johannesburg, South Africa"`, website enrichment and reviews enabled):

```json
{
    "businessName": "Houghton Dental",
    "category": "Dentist",
    "categories": ["Dentist"],
    "rating": 5,
    "reviewCount": 147,
    "address": "53 2nd Avenue, The Houghton Medical Suites Gate 2, The Houghton Hotel, Houghton Estate, Johannesburg, 2198, South Africa",
    "street": "53 2nd Avenue",
    "city": "Johannesburg",
    "region": null,
    "postalCode": "2198",
    "country": "South Africa",
    "latitude": -26.1674613,
    "longitude": 28.0743136,
    "phone": "+27748040271",
    "phones": ["+27748040271", "+27110834693"],
    "internationalPhone": "+27 74 804 0271",
    "email": "smile@houghtondental.co.za",
    "emails": [
        { "email": "smile@houghtondental.co.za", "type": "other", "matchesWebsiteDomain": true, "sources": ["https://www.houghtondental.co.za/", "https://www.houghtondental.co.za/book/"] }
    ],
    "whatsapp": "https://wa.me/27748040271",
    "whatsappNumbers": ["+27748040271"],
    "website": "http://www.houghtondental.co.za",
    "googleMapsUrl": "https://www.google.com/maps/search/?api=1&query=Houghton%20Dental&query_place_id=ChIJO7kvs7wNlR4R0cnV1vKyF84",
    "placeId": "ChIJO7kvs7wNlR4R0cnV1vKyF84",
    "cid": "14850535052333664721",
    "openingHours": { "Monday": "8 am–5 pm", "Saturday": "8 am–1 pm", "Sunday": "Closed" },
    "priceLevel": null,
    "socialProfiles": {
        "facebook": "https://www.facebook.com/houghtondental1",
        "instagram": "https://www.instagram.com/houghtondental",
        "linkedin": null,
        "tiktok": null,
        "youtube": null,
        "twitter": null,
        "whatsapp": "https://wa.me/27748040271"
    },
    "contactPeople": [],
    "reviews": [
        { "reviewId": "Ci9DQUlRQUNv…", "rating": 5, "text": "If you're considering cosmetic dentistry, I cannot recommend…", "reviewDate": "2 months ago", "reviewerName": "…", "reviewUrl": null, "sentiment": "positive" }
    ],
    "overallSentimentScore": 98,
    "topPositiveTopics": ["atmosphere", "communication", "service", "staff", "professionalism"],
    "topNegativeTopics": [],
    "leadScore": 79,
    "leadScoreBreakdown": { "contactability": 30, "onlinePresence": 15, "socialPresence": 6, "reviewStrength": 18, "marketingOpportunity": 10 },
    "marketingOpportunity": "HIGH",
    "marketingOpportunities": [
        "No LinkedIn profile linked from the website",
        "No TikTok profile linked from the website",
        "No YouTube profile linked from the website",
        "No X/Twitter profile linked from the website",
        "Business has more than 100 Google reviews",
        "High Google rating (5)",
        "Public phone number available",
        "Public email address available",
        "Public WhatsApp contact available"
    ],
    "enrichmentStatus": { "website": "success", "reviews": "success" },
    "errors": [],
    "queryUsed": "dentists",
    "matchedQueries": ["dentists"],
    "searchPosition": 1,
    "scrapedAt": "2026-09-13T17:06:18.115Z"
}
```

### Lead scoring methodology

`leadScore` (0–100) is the sum of five categories. It summarizes **how reachable and how visible a business is, plus observable gaps**. It is **not** a prediction of conversion, revenue or buying intent.

| Category | Max | Points |
| --- | --- | --- |
| **Contactability** | 30 | Phone 12 · Email 12 · WhatsApp 6 |
| **Online presence** | 20 | Website listed 8 · HTTPS 3 · Website loaded successfully 4 · Contact page found 2 · Meaningful description 3 |
| **Social presence** | 15 | Facebook 3 · Instagram 3 · LinkedIn 3 · TikTok 2 · YouTube 2 · X/Twitter 2 |
| **Review strength** | 20 | Rating: ≥4.5 → 10, ≥4.0 → 8, ≥3.5 → 5, ≥3.0 → 3, lower → 1, none → 0. Review count: ≥200 → 10, ≥100 → 8, ≥50 → 6, ≥20 → 4, ≥5 → 2, ≥1 → 1 |
| **Marketing opportunity** | 15 | No website 5 · 4+ social channels missing 4 (2–3 missing: 2) · Strong Maps presence (rating ≥4.0 and ≥50 reviews) 3 · Contactable by phone/email/WhatsApp 3 |

**Marketing opportunity level:** opportunity points ≥10 → `HIGH`, ≥5 → `MEDIUM`, otherwise `LOW`.

`marketingOpportunities` lists the observable facts behind the level, e.g. *"No website detected on the Google Maps listing"*, *"No Instagram profile linked from the website"*, *"Business has more than 100 Google reviews"*, *"Public phone number available"*.

Notes:

- Missing social channels are only counted and reported when the website was actually crawled and social extraction was enabled. With `enrichWebsite: false`, the Actor makes no claims about social profiles.
- A missing website lowers *online presence* but raises *marketing opportunity* on purpose: the two categories answer different questions.
- The score depends on data Google and the website expose at run time, so it can differ between runs.

### Review sentiment methodology

No AI and no external service — a transparent word-list approach:

1. **Text score** (−1…1): counts positive words (e.g. *excellent, friendly, professional*) and negative words (e.g. *rude, dirty, overpriced*), with simple negation handling (*"not good"* counts as negative).
2. **Review score** = 70% star rating (`(rating − 3) / 2`) + 30% text score; text only when there is no rating.
3. **Label:** > 0.25 → `positive`, < −0.25 → `negative`, otherwise `neutral`.
4. **`overallSentimentScore`** = mean review score mapped to 0–100 (`null` without reviews).
5. **Topics:** a keyword dictionary (staff, service, professionalism, price, wait time, cleanliness, booking, communication, quality, location, atmosphere, food). The most frequent topics in positive and negative reviews become `topPositiveTopics` / `topNegativeTopics`.

### Performance

Measured on the Apify platform (September 2026, Johannesburg dentists, website enrichment on):

| Run | Proxy | Reviews | Memory / concurrency | Leads | Time | Cost | Per lead |
| --- | --- | --- | --- | --- | --- | --- | --- |
| A | Apify Proxy, automatic (datacenter) | off | 4 GB / 5 | 26 | 5.5 min | **$0.08** | **$0.003** |
| B | Apify Proxy, RESIDENTIAL | on (5 each) | 4 GB / 10 | 16 | 6.2 min | $0.23 | $0.015 |
| C | Apify Proxy, RESIDENTIAL | on (5 each) | 2 GB / 5 | 5 | 2.5 min | $0.11 | $0.022 |

Where the money goes:

- **Compute** is roughly constant: about **$0.003–0.005 per lead** (≈ 0.02 compute units), regardless of memory — doubling memory doubles the CPU share and halves the run time at the same cost.
- **Residential proxy bandwidth** ($8/GB) is the only expensive input. The Actor keeps it low by blocking map tiles, avatars, fonts and telemetry at the browser level while keeping the HTTP cache, so the multi-megabyte Google Maps app is downloaded **once per browser**, not once per page. Fixed cost is ≈ 4 MB per browser plus ≈ 3 MB per search; each additional business is only ≈ 0.1–0.3 MB. Small runs therefore look expensive per lead; a 100-lead run costs ≈ 30–40 MB (≈ $0.30).
- **Datacenter proxy traffic is not billed per GB**, which is why run A is 5× cheaper than run B. Business websites are fetched without a proxy by default (see `websiteProxyConfiguration`).

Speed: the work is browser-bound, so throughput scales with CPU, and on Apify CPU scales with memory (4 GB ≈ 1 CPU). Rules of thumb after ~30 s of start-up: **≈ 4 s per business without reviews and ≈ 8 s with reviews per 4 GB of memory** (e.g. 100 leads with reviews ≈ 10–13 min at 4 GB, ≈ 6–7 min at 8 GB with `maxConcurrency` 20). Options that add time: `includeReviews` (largest), `maxWebsitePages`, very large `maxResultsPerQuery` (feed scrolling).

Cheapest configuration: default proxy (automatic), `includeReviews: false`, `maxWebsitePages: 3`.

### Proxy configuration

- **Default: `{ "useApifyProxy": true }`** (automatic datacenter group). In our tests Google Maps served full results through it, and datacenter traffic is not billed per GB — start here.
- **If Google blocks the run** (CAPTCHA, "unusual traffic", `RUN_SUMMARY.blockedByGoogle`) or hides reviews behind its limited view, switch `proxyConfiguration` to `{ "useApifyProxy": true, "apifyProxyGroups": ["RESIDENTIAL"], "apifyProxyCountry": "ZA" }` (pick the country of your searches). Residential bandwidth costs about $8/GB; see Performance for what that means per lead.
- **Business websites** use `websiteProxyConfiguration`, default **no proxy** (cheap, direct requests). Sites that answer with a bot-protection challenge are automatically retried once through the Google Maps proxy; the few that still block are reported as `enrichmentStatus.website: "blocked"`. Set a DATACENTER or RESIDENTIAL group here only if many sites in your market block direct requests.
- Custom proxies: `{ "useApifyProxy": false, "proxyUrls": ["http://user:pass@host:port"] }`. Credentials come only from input and are never logged.
- Running locally without an Apify token, the Actor continues without a proxy and logs a warning.
- The Actor never tries to solve or bypass CAPTCHAs. Session rotation is capped and blocked requests are not retried endlessly.

### Limitations

- **Google Maps "limited view".** Google may serve signed-out visitors a reduced page without review counts, the Reviews tab or some About sections. When that happens, `reviewCount` is `null` and `enrichmentStatus.reviews` is `not-available` — values are never estimated. In our tests this varied from page load to page load.
- **Google result caps.** A single search usually lists up to ~120 places. Use narrower queries or locations (e.g. suburbs) for broader coverage.
- **Selectors can change.** Google changes its interface frequently. Extraction relies on stable attributes (`data-item-id`, roles, ARIA labels) with fallbacks, but fields may temporarily go missing after a Google update.
- **JavaScript-only websites.** Website enrichment uses fast HTTP requests without a browser. Sites that render their content only with JavaScript may yield fewer emails, services or people.
- **Obfuscated emails** (e.g. Cloudflare email protection, images of emails) are intentionally not decoded.
- **Address parsing** splits the address Google displays. Components that cannot be identified confidently (often `region`) are `null`.
- **Review dates** are Google's relative dates ("2 months ago"). `reviewUrl` is `null` because Google does not expose a stable public link per review.
- **Contact people** are only found when a business publishes names next to roles; many small-business sites do not.
- **CAPTCHA/blocking:** if Google blocks the environment, affected searches are reported in `RUN_SUMMARY` and the run fails with a clear message if nothing could be extracted.
- **Very large runs** keep their progress state in the key-value store for migration recovery. For tens of thousands of businesses with reviews, split the work into several runs (e.g. per suburb or query group).
- The lead score is a heuristic summary, not a conversion prediction.

### Responsible usage

This Actor collects **only publicly accessible business information**: Google Maps listings, public reviews, and pages a business publishes on its own website.

It does **not**:

- bypass authentication, CAPTCHAs, paywalls or other security controls,
- access private accounts or logged-in content,
- use leaked or purchased data,
- perform people-search or personal-data enrichment,
- guess, generate or fabricate emails, phone numbers or social profiles.

It respects `robots.txt` on business websites and limits how many pages it crawls per site.

You are responsible for using the data lawfully. In particular:

- Comply with the privacy and marketing laws that apply to you and your recipients (e.g. **GDPR**, **POPIA** in South Africa, **CAN-SPAM**, **PECR**, **CASL**) before contacting anyone, and honour opt-outs.
- Review names in `contactPeople` and `reviews[].reviewerName` as personal data. Disable `extractContactPeople` or `includeReviews` if you do not need them.
- Respect Google's and websites' terms of service and keep request volumes reasonable.

### Troubleshooting

| Symptom | What to do |
| --- | --- |
| `Invalid input: ...` | Fix the listed fields; every problem is reported at once. |
| Run fails with *"Google Maps blocked the requests"* | Use Apify Proxy **RESIDENTIAL**, lower `maxConcurrency`, try again later. Check `RUN_SUMMARY.searchQueries[].status`. |
| Fewer results than `maxResultsPerQuery` | Google listed fewer places for that search. Use more specific locations or additional queries. |
| `reviewCount`/`reviews` empty with `enrichmentStatus.reviews: "not-available"` | Google served its limited view. Try another proxy group or country. |
| `enrichmentStatus.website: "failed"` | See `errors[]`, e.g. *"Website request timed out"*. The site was down, too slow or rejected the request. |
| `enrichmentStatus.website: "skipped"` | Website disallows crawling in `robots.txt`, or the listed website is a social profile. |
| `enrichmentStatus.website: "blocked"` | The site answered with a bot-protection challenge (Cloudflare etc.) even after the retry through the Google Maps proxy. Set `websiteProxyConfiguration` to a RESIDENTIAL group for those runs. |
| No emails although the site shows one | The email may be rendered by JavaScript, shown as an image or obfuscated. |
| Memory errors | Lower `maxConcurrency` or increase Actor memory. |

Set the environment variable `CRAWLEE_LOG_LEVEL=DEBUG` for detailed logs.

### Local development

Requirements: Node.js 20+ (24 recommended), npm, optionally Docker.

```bash
npm install
npx playwright install chromium        # browser for local runs and browser tests

npm run build                           # compile TypeScript to dist/
npm test                                # unit + mocked integration tests (no network)
npm run test:browser                    # page-script tests in headless Chromium (no network)
npm run lint                            # ESLint + type check
```

Run the Actor locally. Input is read from `storage/key_value_stores/default/INPUT.json`:

```bash
mkdir -p storage/key_value_stores/default
cat > storage/key_value_stores/default/INPUT.json <<'JSON'
{ "searchQueries": ["dentists"], "location": "Johannesburg, South Africa", "maxResultsPerQuery": 5 }
JSON
npm run start:dev          # build + run; results in storage/datasets/default/
```

Or with the Apify CLI: `apify run`.

Smoke tests (live Google Maps, tiny configuration):

```bash
npm run smoke              # builds and runs dist/main.js, then verifies the dataset
npm run smoke:docker       # builds the Docker image and runs the same checks inside it
```

The smoke test exits with code `2` (not success) when Google blocks the environment, so a block is never reported as a pass.

Docker (the Apify Playwright base image is `linux/amd64`; Apple Silicon machines run it under emulation):

```bash
docker build --platform linux/amd64 -t google-maps-lead-finder .
docker run --rm --platform linux/amd64 \
  -v "$PWD/storage:/home/myuser/storage" -e CRAWLEE_STORAGE_DIR=/home/myuser/storage \
  google-maps-lead-finder
```

No environment variables are required. See `.env.example` for optional ones.

### Apify deployment

1. Install the CLI and log in: `npm install -g apify-cli` and `apify login`.
2. From the project directory run `apify push`. The platform builds the Dockerfile (`apify/actor-node-playwright-chrome:24-1.63.0`).
3. In Apify Console open the Actor → **Input**, enter queries and a location, keep the default proxy (switch to RESIDENTIAL only if Google blocks the run), and click **Start**.
4. Default run memory is 4096 MB (`maxConcurrency` 10). Use 8192 MB with `maxConcurrency` 20 for faster large runs; increase the timeout for very large runs.
5. Export results from the **Storage → Dataset** tab (JSON, CSV, Excel), or use the API or integrations.

Alternatively, connect the Git repository in Console (**Source: Git repository**) so every push rebuilds the Actor.

### Pricing considerations

Measured platform cost is ≈ **$3 per 1,000 leads** with the default datacenter proxy and website enrichment, and ≈ **$8–15 per 1,000** with residential proxy and reviews (see Performance).

Suggested Store pricing (starting points):

- **Pay per result: $5 per 1,000 leads** for the base run, which still leaves margin on the datacenter configuration. If you want one price that also covers residential/reviews runs, **$10 per 1,000**.
- **Pay per event** (needs `Actor.charge()` calls, not in V1): ≈ $0.003 per lead discovered + $0.002 per website enriched + $0.003 per business with reviews.

Re-measure with `RUN_SUMMARY` and the run's usage tab after any Google or platform change.

### Architecture

```
src/
├── main.ts                 Actor entry: init, input, proxy, run, exit status
├── input.ts                zod validation, defaults, query dedupe
├── proxy.ts                Apify/custom proxy configuration
├── pipeline/
│   ├── run.ts              discovery → website enrichment → output (resumable phases)
│   ├── state.ts            persisted run state (survives migrations)
│   └── output.ts           lead building, post-processor hook, dataset push
├── google-maps/
│   ├── crawler.ts          PlaywrightCrawler: search feed, place pages, reviews, consent, blocking
│   ├── page-scripts.ts     in-browser DOM readers (raw strings only)
│   ├── parser.ts           pure parsing: rating, reviews, address, hours, IDs
│   └── types.ts
├── website/
│   ├── crawler.ts          CheerioCrawler with per-site page budget, robots.txt
│   ├── parser.ts           page extraction, link prioritization, merging
│   ├── html.ts             Cheerio loader
│   └── types.ts
├── enrichment/             email.ts · phone.ts (incl. WhatsApp) · social.ts · contact-people.ts
├── reviews/sentiment.ts    deterministic sentiment + topics
├── scoring/lead-score.ts   lead score, opportunity level and facts
├── lead/                   output record type + builder
└── utils/                  normalize.ts · deduplicate.ts · urls.ts · errors.ts
```

**Extensibility:** `LEAD_POST_PROCESSORS` in `src/pipeline/output.ts` receives each finished, deterministic `LeadRecord` and can add fields. V1 registers none. Future versions can add optional processors (e.g. summaries or qualification) without touching discovery, enrichment or scoring.

# Actor input Schema

## `searchQueries` (type: `array`):

What to search for on Google Maps, one query per line, e.g. "dentists", "car dealerships", "real estate agents". Duplicate queries are removed automatically. Businesses found by several queries appear only once.

## `location` (type: `string`):

Where to search, e.g. "Johannesburg, South Africa". It is appended to every query as "<query> in <location>". Leave empty if your queries already contain a location.

## `maxResultsPerQuery` (type: `integer`):

Maximum number of Google Maps results to collect for each search query. Google Maps typically shows up to about 120 results per search; use more specific queries or locations to find more businesses.

## `includeReviews` (type: `boolean`):

Extract public Google Maps reviews and compute a deterministic sentiment score and recurring topics. Adds time per business. Google may not expose reviews to signed-out visitors ("limited view"); in that case reviews are reported as not available.

## `maxReviewsPerBusiness` (type: `integer`):

Maximum number of reviews to extract per business when review extraction is enabled.

## `enrichWebsite` (type: `boolean`):

Visit each business's official website (as listed on Google Maps) to extract contact details, social profiles, description and services. When disabled, no websites are visited.

## `maxWebsitePages` (type: `integer`):

Maximum number of pages crawled per website, including the homepage. Contact, about, team, services and locations pages are prioritized. robots.txt is respected.

## `extractEmails` (type: `boolean`):

Extract publicly displayed email addresses (mailto links and visible text) from the business website. Emails are validated, classified (general, sales, support, bookings, marketing, contact) and never guessed.

## `extractPhones` (type: `boolean`):

Extract additional phone numbers and WhatsApp click-to-chat links from the business website. The phone number shown on the Google Maps listing is always included.

## `includeSocialProfiles` (type: `boolean`):

Extract Facebook, Instagram, LinkedIn, TikTok, YouTube and X/Twitter profiles that are explicitly linked from the official website. Profiles are never guessed.

## `extractContactPeople` (type: `boolean`):

Extract names and roles that the business itself publishes on its about, team, staff or contact pages (e.g. "Jane Smith – Practice Manager"). No people-search or third-party enrichment is performed.

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

Maximum number of Google Maps pages processed in parallel. One browser serves 5 pages; allow about 1 GB of memory per 5 pages (default 10 → 4 GB). Higher values are faster at the same compute cost per lead but more likely to be rate-limited by Google.

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

Proxy for Google Maps requests. The default automatic Apify Proxy (datacenter) worked in our tests and is not billed per GB. Switch to the RESIDENTIAL group (with the country of your searches) only if Google blocks the run or hides reviews.

## `websiteProxyConfiguration` (type: `object`):

Proxy used only for fetching business websites. Default: no proxy — plain website pages are cheap to fetch directly and do not need residential bandwidth. Set a DATACENTER group if some websites block Apify IPs.

## Actor input object example

```json
{
  "searchQueries": [
    "dentists",
    "dental clinics"
  ],
  "location": "Johannesburg, South Africa",
  "maxResultsPerQuery": 20,
  "includeReviews": false,
  "maxReviewsPerBusiness": 10,
  "enrichWebsite": true,
  "maxWebsitePages": 5,
  "extractEmails": true,
  "extractPhones": true,
  "includeSocialProfiles": true,
  "extractContactPeople": true,
  "maxConcurrency": 10,
  "proxyConfiguration": {
    "useApifyProxy": true
  },
  "websiteProxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `leads` (type: `string`):

All extracted leads as JSON: business details, contact channels (phone, email, WhatsApp), social profiles, contact people, optional reviews with sentiment, lead score and marketing opportunities. Append ?format=csv or ?format=xlsx for other formats.

## `leadsCsv` (type: `string`):

The same leads as a CSV file.

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

Per-query results, duplicates skipped, websites crawled/failed/blocked, records pushed and whether Google blocked any request.

# 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 = {
    "searchQueries": [
        "dentists",
        "dental clinics"
    ],
    "location": "Johannesburg, South Africa",
    "maxResultsPerQuery": 20,
    "proxyConfiguration": {
        "useApifyProxy": true
    },
    "websiteProxyConfiguration": {
        "useApifyProxy": false
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("outspoken_strategy/google-maps-lead-finder").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 = {
    "searchQueries": [
        "dentists",
        "dental clinics",
    ],
    "location": "Johannesburg, South Africa",
    "maxResultsPerQuery": 20,
    "proxyConfiguration": { "useApifyProxy": True },
    "websiteProxyConfiguration": { "useApifyProxy": False },
}

# Run the Actor and wait for it to finish
run = client.actor("outspoken_strategy/google-maps-lead-finder").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 '{
  "searchQueries": [
    "dentists",
    "dental clinics"
  ],
  "location": "Johannesburg, South Africa",
  "maxResultsPerQuery": 20,
  "proxyConfiguration": {
    "useApifyProxy": true
  },
  "websiteProxyConfiguration": {
    "useApifyProxy": false
  }
}' |
apify call outspoken_strategy/google-maps-lead-finder --silent --output-dataset

```

## MCP server setup

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

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/7w4asXHVLwbXghSIz/builds/ldUTS9zkD2u4dPjVe/openapi.json
