---
name: "Verified email finder"
repo: https://github.com/apify/awesome-skills
source: https://github.com/apify/awesome-skills/blob/HEAD/skills/apify-verified-email-finder/SKILL.md
license: "Apache-2.0"
install: "npx skills add apify/awesome-skills --skill apify-verified-email-finder"
page: https://100xlab.com/resources/agent-skills/apify-awesome-skills--apify-verified-email-finder--awglpb
---

# Verified Email Finder

Return a list of verified business emails by routing the user's input to the right Apify Actor and turning on the leads enrichment + email verification add-ons in a single run. No third-party verifier (Hunter, NeverBounce, Apollo) needed — verification happens inside the same Actor run.

## Prerequisites
(No need to check it upfront)

The skill supports two execution paths. Pick the one that matches your environment — Steps 4 and 5 show commands for both.

**MCP path (default in Claude sessions, recommended).** If the Apify MCP server is connected, no setup is needed — auth runs through the user's Apify account. Use the `call-actor` and `get-dataset-items` MCP tools.

**Script path (CLI / scheduled / non-Claude execution).** Requires:
- `.env` file with `APIFY_TOKEN`
- Node.js 20.6+ (for native `--env-file` support)

## Workflow

Copy this checklist and track progress:

```
Task Progress:
- [ ] Step 1: Collect the six required anchor inputs
- [ ] Step 2: Route to the correct Actor (confirm if ambiguous)
- [ ] Step 3: Build the Actor input (verification always ON)
- [ ] Step 4: Run the Actor and wait
- [ ] Step 5: Apply the result-scope filter, deduplicate, and render
```

### Step 1: Collect the Six Required Anchor Inputs

Ask all six as one block before any Actor call. Don't bundle Actor-specific optional fields (country code, language, max pages) into this round — surface those as follow-ups.

1. **What do you have to start with?** — `location query` / `SERP keyword` / `URL list`. This drives the routing decision.
2. **The actual input** — the location string, the keyword(s), or the URLs themselves.
3. **Department filter** — one or more of: `c_suite`, `product`, `engineering_technical`, `design`, `education`, `finance`, `human_resources`, `information_technology`, `legal`, `marketing`, `medical_health`, `operations`, `sales`, `consulting`. Default is `any` (leave the array empty), but ask every time.
4. **Max contacts per domain / business** — passed as `maximumLeadsEnrichmentRecords`. Default `3`, but ask every time.
5. **Output format** — `CSV` or `JSON`. Ask every time.
6. **Result scope** — which leads to keep in the deliverable. The Actor always runs the same way (verification always on); this only controls post-run filtering. Pick one:
   - `verified-only` (default) — only leads with `emailVerification.result == "ok"`. Safest for cold email.
   - `verified-plus-catchall` — `ok` plus `catch_all`. Catch-all is often deliverable but unprovable.
   - `all-emails` — any lead with a non-empty `email`, regardless of verification.
   - `with-phone` — any lead with a non-empty phone number, regardless of email status. Use for call campaigns.
   - `everything` — every lead the Actor returned, even incomplete ones.


### Step 2: Route to the Correct Actor

Inspect anchor #1 and pick the Actor.

| User has to start with | Actor ID | Use when |
|---|---|---|
| Location + business type ("dentists in Berlin") | `compass/crawler-google-places` | Local leads list from Maps listings; best when user wants address / phone / hours too |
| Keyword / search query ("best CRM software") | `apify/google-search-scraper` | Contacts from whichever sites Google ranks for a topic |
| Pre-existing URL list (pasted, file path) | `vdrmota/contact-info-scraper` | User already has domains; cheapest route since no discovery step |

All three Actors share the same three add-on fields, so verification behavior is identical across routes.

**Decision examples**

| User says | Route |
|---|---|
| "Dentists in Munich" / "Lawyers in Prague" | Maps |
| "Marketing contacts at the top results for 'AI agent builder'" | Search |
| "Find emails for these 5 URLs: acme-co.example, demo-co.example..." | URL list |
| "Find HR contacts at Fortune 500 companies" | **Ask:** SERP for "Fortune 500 HR" or a URL list? |
| "Find contacts at SaaS companies in Berlin" | **Ask:** Maps for "SaaS companies in Berlin" or SERP for "SaaS companies Berlin"? Maps works best when businesses are Google-Maps-listed. |
| (User pastes both a SERP keyword AND a URL list) | **Ask:** run one route, the other, or both as separate deliverables? |

**Ambiguity rule:** if anchor #1 is unclear, ask **one** follow-up before running. Never burn Actor compute on a guessed route.

**Mixed deliverables:** if the user explicitly asks for two routes in one deliverable, run both Actors and concatenate. The `Source` column makes the mix clear; dedupe by email across the combined output.

### Step 3: Build the Actor Input

Always set these three fields, regardless of which Actor is selected.

| Field | Value |
|---|---|
| `maximumLeadsEnrichmentRecords` | anchor #4 (default `3`, min `1`) |
| `leadsEnrichmentDepartments` | anchor #3 as array, or `[]` if "any" |
| `verifyLeadsEnrichmentEmails` | `true` (always) — guard rail, never set to `false` |

Full per-Actor input parameters and example payloads are in [reference/apify-actor-usage.md](reference/apify-actor-usage.md).

**URL-list pre-validation:** before submitting URLs to `vdrmota/contact-info-scraper`, parse each one and check it is http/https and parseable. Skipped entries must appear in the output as `skipped — invalid URL`, never silently dropped.

### Step 4: Run the Actor

Maps and SERP runs with leads enrichment can take several minutes per query. Raise the timeout for large jobs.

**MCP path (default in Claude sessions):**

Call the `call-actor` tool:
- `actor`: one of the three Actor IDs (`compass/crawler-google-places`, `apify/google-search-scraper`, `vdrmota/contact-info-scraper`)
- `input`: the JSON payload from Step 3
- `callOptions`: `{"timeout": 1800, "memory": 4096}` for a generous budget

The tool returns `runId` and `datasetId`. If `status` is still `RUNNING`, poll with `get-actor-run` (waitSecs up to 45) until `SUCCEEDED`. Capture both IDs for the `run_metadata.json` sidecar.

**Script path (CLI / scheduled use):**

```bash
node --env-file=.env ${CLAUDE_PLUGIN_ROOT}/reference/scripts/run_actor.js \
  --actor "ACTOR_ID" \
  --input 'JSON_INPUT' \
  --output YYYY-MM-DD_verified-emails.csv \
  --format csv \
  --timeout 900
```

Use `--format json` for JSON. The script writes the raw dataset to disk; Step 5 still applies the spurious-match + scope filters on top.

### Step 5: Filter, Deduplicate, and Render

Pull the dataset:
- **MCP path:** call `get-dataset-items` with the `datasetId` from Step 4. Use the `fields` parameter (e.g., `title,searchString,countryCode,city,address,phone,website,leadsEnrichment`) and `clean: true` to keep the response small. For datasets that still exceed the response cap, fetch directly via `curl https://api.apify.com/v2/datasets/<id>/items?fields=...&clean=true` and pipe through `jq`.
- **Script path:** the raw dataset is already on disk in the file from Step 4.

Each record contains business fields plus a `leadsEnrichment` array (Maps, SERP) or top-level lead fields (URL list). Each lead has a `departments` array, a `companyWebsite`, and an `emailVerification` object with `result` (`ok` / `invalid` / `disposable` / `catch_all` / `unknown` / `error`) and `quality` (`good` / `risky` / `bad`).

- **Spurious-match filter (mandatory, always on).** Apply this **first**, before any other filter. The lead-enrichment service can return global-fallback leads when no local match exists (real case observed: a single US-zoo CFO whose `companyWebsite=zoo.org` was attributed to 8 unrelated Polish zoos because the matcher latched onto the `zoo` substring). Drop any lead whose `companyWebsite` hostname doesn't equal the source URL's hostname (strip `https?://`, leading `www.`, anything after `/`; lowercase). Count drops in `run_metadata.json` and call them out in the deliverable header if non-zero.

- **Filter by result scope** (anchor #6). Applied second.

  | Scope | Row-keep logic |
  |---|---|
  | `verified-only` | `emailVerification.result == "ok"` |
  | `verified-plus-catchall` | `emailVerification.result in {"ok", "catch_all"}` |
  | `all-emails` | `email` is non-empty (any result, including missing verification) |
  | `with-phone` | `phone` (or company phone) is non-empty (regardless of email) |
  | `everything` | keep every lead, no filter |

- **Dedupe:** group by lowercased email; keep the first occurrence and merge `Source Query or URL` if the same email appears from multiple sources. For `with-phone` rows that have no email, dedupe by lowercased phone instead.
- **Empty-result surfacing:** if the department filter (anchor #3) produces zero leads for a given domain, include a row for that domain with `Email = ""` and `Email Verification Status = "no leads matched filter"`. Do not silently drop it. This is separate from the result-scope filter above — empty-domain rows are inserted before scope-filtering and always shown.

Output row schema (16 columns, including `Departments`) and per-format rendering details are in [reference/output-formats.md](reference/output-formats.md).

## Worked Examples

- Maps: [examples/example-maps-input.md](examples/example-maps-input.md)
- SERP: [examples/example-search-input.md](examples/example-search-input.md)
- URL list: [examples/example-url-list-input.md](examples/example-url-list-input.md)

## Quality Rules (always enforce)

- **Guard rail:** never submit a run with `verifyLeadsEnrichmentEmails: false`.
- **Provenance & traceability:** populate the `Source` column on every row; carry Apify `runId` + `datasetId` in `run_metadata.json`.
- **No fabrication:** missing dataset fields stay blank.
- **Deliverable header transparency:** state the active result scope and the spurious-match drop count; offer to re-render under a different scope.
- **Ambiguity confirm:** if anchor #1 is unclear, ask before running.

## Cost & Pricing

Email verification is charged only for **decisive** results (`ok` / `invalid` / `disposable`); `catch_all` / `unknown` / `error` are free. Leads enrichment is charged per successfully extracted lead. Check the Apify console for live rates (they vary by subscription tier and change over time).

## Error Handling

See [reference/troubleshooting.md](reference/troubleshooting.md).

---

## Supporting file: examples/example-maps-input.md

# Example — Maps route ("dentists in Berlin")

## Anchors

| # | Value |
|---|---|
| 1 What do you have | location query |
| 2 Input | `dentists in Berlin` |
| 3 Departments | `marketing`, `c_suite` |
| 4 Max contacts | `3` |
| 5 Format | CSV |
| 6 Scope | `verified-only` |

Optional follow-ups: `language=en`, `maxCrawledPlacesPerSearch=20`, `scrapePlaceDetailPage=true` (so address / phone come back).

Routing: unambiguous → `compass/crawler-google-places`.

## Actor input

```json
{
  "searchStringsArray": ["dentists"],
  "locationQuery": "Berlin, Germany",
  "maxCrawledPlacesPerSearch": 20,
  "language": "en",
  "scrapePlaceDetailPage": true,
  "maximumLeadsEnrichmentRecords": 3,
  "leadsEnrichmentDepartments": ["marketing", "c_suite"],
  "verifyLeadsEnrichmentEmails": true
}
```

Run it via the MCP `call-actor` tool or the script (SKILL.md Step 4). `expected_leads = 20 × 3 = 60` — under the 200-lead warning threshold, no confirm needed.

## Sample output

| Source | Business | Full Name | Job Title | Seniority | Email | Status | Quality | Phone | City |
|---|---|---|---|---|---|---|---|---|---|
| Maps | Example Dental A | Sample Contact 1 | Marketing Lead | manager | contact1@dental-a.example | ok | good | +49 30 5550001 | Berlin |
| Maps | Example Dental B | Sample Contact 2 | Owner | c_suite | contact2@dental-b.example | ok | good | +49 30 5550002 | Berlin |
| Maps | Example Dental C | Sample Contact 3 | CMO | c_suite | contact3@dental-c.example | ok | good | +49 30 5550003 | Berlin |

Deliverable header: *Scope `verified-only`, spurious-match drops: 0. Ask to re-render under a wider scope to include catch-all / unknown.* A `run_metadata.json` sidecar is written next to the CSV.

---

## Supporting file: examples/example-search-input.md

# Example — SERP route ("best CRM software")

## Anchors

| # | Value |
|---|---|
| 1 What do you have | SERP keyword |
| 2 Input | `best CRM software` |
| 3 Departments | `sales` |
| 4 Max contacts | `3` |
| 5 Format | JSON |
| 6 Scope | `verified-only` |

Optional follow-ups: `countryCode=us`, `languageCode=en`, `maxPagesPerQuery=1`.

Routing: unambiguous → `apify/google-search-scraper`.

## Actor input

```json
{
  "queries": "best CRM software",
  "maxPagesPerQuery": 1,
  "countryCode": "us",
  "languageCode": "en",
  "maximumLeadsEnrichmentRecords": 3,
  "leadsEnrichmentDepartments": ["sales"],
  "verifyLeadsEnrichmentEmails": true
}
```

Run via MCP `call-actor` or the script (SKILL.md Step 4). `expected_leads ≈ 10 × 3 = 30` — well under the warning threshold.

## Sample output (truncated to one contact)

```json
{
  "runMetadata": {
    "runId": "AbCdEfGhIjK",
    "datasetId": "LmNoPqRsTuV",
    "actor": "apify/google-search-scraper",
    "finishedAt": "2026-05-18T13:02:11Z",
    "consoleUrl": "https://console.apify.com/actors/runs/AbCdEfGhIjK"
  },
  "filter": {"scope": "verified-only", "rowKeepLogic": "emailVerification.result == 'ok'"},
  "contacts": [
    {
      "source": "Search",
      "sourceQueryOrUrl": "best CRM software",
      "business": "acme-crm.example",
      "fullName": "Sample Contact 1",
      "jobTitle": "Account Executive",
      "seniority": "manager",
      "email": "contact1@acme-crm.example",
      "emailVerificationStatus": "ok",
      "emailVerificationQuality": "good",
      "linkedin": "http://www.linkedin.com/in/example-user-1",
      "businessWebsite": "https://www.acme-crm.example",
      "dateScraped": "2026-05-18T13:02:11Z"
    }
    /* + 2 more, schema in reference/output-formats.md */
  ]
}
```

A `run_metadata.json` sidecar is written next to the JSON output.

---

## Supporting file: examples/example-url-list-input.md

# Example — URL-list route (5 URLs in)

## Anchors

| # | Value |
|---|---|
| 1 What do you have | URL list |
| 2 Input | `https://acme-pay.example, https://demo-card.example, https://sample-bank.example, htp://broken-url, https://example-biz.example` |
| 3 Departments | `any` (empty array) |
| 4 Max contacts | `2` |
| 5 Format | CSV |
| 6 Scope | `with-phone` (call campaign — keep any lead that has a phone, regardless of email) |

Optional follow-ups: `maxRequestsPerStartUrl=10`, `maxDepth=2`, `mergeContacts=true`.

Routing: unambiguous → `vdrmota/contact-info-scraper`.

## Pre-validation

`htp://broken-url` is skipped (scheme not http/https) → emitted as a `skipped — invalid URL` row. The other four URLs go to the Actor.

## Actor input (only valid URLs)

```json
{
  "startUrls": [
    {"url": "https://acme-pay.example"},
    {"url": "https://demo-card.example"},
    {"url": "https://sample-bank.example"},
    {"url": "https://example-biz.example"}
  ],
  "maxRequestsPerStartUrl": 10,
  "maxDepth": 2,
  "mergeContacts": true,
  "proxyConfig": {"useApifyProxy": true},
  "maximumLeadsEnrichmentRecords": 2,
  "leadsEnrichmentDepartments": [],
  "verifyLeadsEnrichmentEmails": true
}
```

Run via MCP `call-actor` or the script (SKILL.md Step 4). `expected_leads = 4 × 2 = 8` — well under the threshold.

## Sample output

Under `with-phone` scope, every row needs a non-empty phone — verification status is informational, not a gate.

| Source | Business | Full Name | Job Title | Email | Status | Quality | Phone |
|---|---|---|---|---|---|---|---|
| URL list | acme-pay.example | Sample Contact 1 | Head of BD | contact1@acme-pay.example | ok | good | +1 415 555 0101 |
| URL list | acme-pay.example | Sample Contact 2 | Director, Partnerships | contact2@acme-pay.example | catch_all | risky | +1 415 555 0102 |
| URL list | demo-card.example | Sample Contact 3 | VP Sales | contact3@demo-card.example | ok | good | +1 415 555 0201 |
| URL list | sample-bank.example | Sample Contact 4 | Growth Lead |  | unknown |  | +1 415 555 0301 |
| URL list | example-biz.example | Sample Contact 5 | Account Manager | contact5@example-biz.example | ok | good | +1 415 555 0401 |
| URL list | htp://broken-url | — | — | — | skipped — invalid URL | — | — |

Deliverable header: *Scope `with-phone`, spurious-match drops: 0, 1 URL pre-skipped. Ask to re-render under `verified-only` to narrow.*

---

## Supporting file: reference/apify-actor-usage.md

# Apify Actor Usage

Exact input parameters per Actor. Every payload includes the three shared add-on fields:

| Field | Value | Notes |
|---|---|---|
| `maximumLeadsEnrichmentRecords` | anchor #4 (default 3, min 1) | `0` disables enrichment — never use. |
| `leadsEnrichmentDepartments` | anchor #3 as array, or `[]` for any | Enum: `c_suite`, `product`, `engineering_technical`, `design`, `education`, `finance`, `human_resources`, `information_technology`, `legal`, `marketing`, `medical_health`, `operations`, `sales`, `consulting`. |
| `verifyLeadsEnrichmentEmails` | `true` always | **Guard rail.** Adds `emailVerification` per lead. Never `false`. |

## 1. Google Maps — `compass/crawler-google-places`

Anchor #1 is a location + business type.

| Field | Type | Required? | Notes |
|---|---|---|---|
| `searchStringsArray` | string[] | yes | Business type(s), e.g. `["dentists"]`. |
| `locationQuery` | string | yes | Free-form location, e.g. `"Berlin, Germany"`. |
| `maxCrawledPlacesPerSearch` | int | optional, default 20 | Places per search string. |
| `language` | string | optional | UI language, e.g. `"en"`. |
| `countryCode` | string | optional | ISO 3166 alpha-2. |
| `city`, `state`, `postalCode` | string | optional | Narrower filters. |
| `scrapePlaceDetailPage` | bool | optional, default `false` | Set `true` for address / hours / phone. |
| `skipClosedPlaces` | bool | optional, default `false` | Drop permanently-closed listings. |

Example:
```json
{
  "searchStringsArray": ["dentists"],
  "locationQuery": "Berlin, Germany",
  "maxCrawledPlacesPerSearch": 20,
  "language": "en",
  "scrapePlaceDetailPage": true,
  "maximumLeadsEnrichmentRecords": 3,
  "leadsEnrichmentDepartments": ["marketing", "c_suite"],
  "verifyLeadsEnrichmentEmails": true
}
```

## 2. Google Search — `apify/google-search-scraper`

Anchor #1 is a keyword.

| Field | Type | Required? | Notes |
|---|---|---|---|
| `queries` | string | yes | Newline-separated queries, each ≤ 32 words. |
| `maxPagesPerQuery` | int | optional, default 1 | Each page ≈ 10 results. |
| `countryCode` | string | optional, default `"us"` | Drives the `google.xx` domain. |
| `languageCode` | string | optional | UI language. |
| `searchLanguage` | string | optional | `lr` filter — restricts result-page language. |
| `mobileResults` | bool | optional, default `false` | Mobile SERP. |

Example:
```json
{
  "queries": "best CRM software",
  "maxPagesPerQuery": 1,
  "countryCode": "us",
  "languageCode": "en",
  "maximumLeadsEnrichmentRecords": 3,
  "leadsEnrichmentDepartments": ["sales"],
  "verifyLeadsEnrichmentEmails": true
}
```

## 3. URL list — `vdrmota/contact-info-scraper`

Anchor #1 is a pre-existing URL list.

| Field | Type | Required? | Notes |
|---|---|---|---|
| `startUrls` | object[] | yes | `[{"url": "https://..."}, ...]`. Pre-validate as http/https; emit a `skipped — invalid URL` row for each rejection. |
| `proxyConfig` | object | yes | Default `{"useApifyProxy": true}` works for most. |
| `maxRequestsPerStartUrl` | int | optional, default 20 | Pages crawled per start URL. |
| `maxDepth` | int | optional, default 2 | Link-depth from start. |
| `mergeContacts` | bool | optional, default `true` | Merge per-domain contacts. Keep on. |
| `sameDomain` | bool | optional, default `true` | Stay inside the start URL's domain. |
| `useBrowser` | bool | optional, default `false` | Headless browser for JS-heavy sites; raises cost. |

Example:
```json
{
  "startUrls": [
    {"url": "https://acme-co.example"},
    {"url": "https://demo-co.example"}
  ],
  "maxRequestsPerStartUrl": 10,
  "maxDepth": 2,
  "mergeContacts": true,
  "proxyConfig": {"useApifyProxy": true},
  "maximumLeadsEnrichmentRecords": 3,
  "leadsEnrichmentDepartments": [],
  "verifyLeadsEnrichmentEmails": true
}
```

---

## Supporting file: reference/output-formats.md

# Output Formats

One row per contact (subject to anchor #6 scope), in CSV or JSON.

## Row schema (16 columns)

The lead object lives on `leadsEnrichment[]` for Maps and SERP; URL-list leads sit on the same field inside a per-domain merged record. Field names are the actual keys the Apify lead-enrichment service returns.

| Column | Source field |
|---|---|
| `Source` | Literal: `Maps`, `Search`, or `URL list` (set by the route used) |
| `Source Query or URL` | Original search string or input URL |
| `Business / Domain` | `title` (Maps) / domain of `url` (SERP) / start URL host (URL list) |
| `Full Name` | `leadsEnrichment[].fullName` |
| `Job Title` | `leadsEnrichment[].jobTitle` |
| `Departments` | `leadsEnrichment[].departments` joined with `\|` (array of enum strings; often empty) |
| `Seniority` | `leadsEnrichment[].seniority` (`entry` / `manager` / `director` / `c_suite` / etc., blank if unavailable) |
| `Email` | `leadsEnrichment[].email` |
| `Email Verification Status` | `leadsEnrichment[].emailVerification.result` (`ok` / `invalid` / `disposable` / `catch_all` / `unknown` / `error`) |
| `Email Verification Quality` | `leadsEnrichment[].emailVerification.quality` (`good` / `risky` / `bad`) |
| `LinkedIn` | `leadsEnrichment[].linkedinProfile` |
| `Phone` | place `phone` if present (Maps) else `leadsEnrichment[].companyPhoneNumber` |
| `City` | `leadsEnrichment[].city` (or place `city` for Maps) |
| `Country` | `leadsEnrichment[].country` (or place `countryCode` for Maps) |
| `Business Address` | place `address` (Maps only; blank for SERP / URL list) |
| `Business Website` | place `website` / `url` / `leadsEnrichment[].companyWebsite` |
| `Date Scraped` | run finish time (ISO 8601) |

**Notes:**

- `departments` is a **plural array** on the lead (not a string field called `department`). Often empty, but populated for ~30% of leads with values like `["marketing"]` or `["c_suite", "finance"]`. Join with `|` for the CSV column.
- Missing fields stay blank — never invent a value.

## Filter & dedupe

1. **Spurious-match filter (mandatory, always on).** The lead-enrichment service sometimes returns global-fallback leads when no local match exists — e.g., a US-zoo CFO with `companyWebsite=zoo.org` attributed to 8 unrelated Polish zoos because the matcher latched onto the `zoo` substring.

   Row-keep logic: extract hostnames (strip `https?://`, leading `www.`, anything after `/`; lowercase) from both the source URL and the lead's `companyWebsite`, and keep only if both non-empty and equal. The source URL is `place.website` (Maps), the SERP result `url` (Search), or the original `startUrls[].url` (URL list). If either hostname is empty, drop the lead.

   Count drops in `run_metadata.json` under `stats.spuriousMatchesDropped` and surface in the deliverable header if non-zero.

2. **Result-scope filter (anchor #6).** Applied after spurious-match.

   | Scope | Row-keep logic |
   |---|---|
   | `verified-only` (default) | `emailVerification.result == "ok"` |
   | `verified-plus-catchall` | `emailVerification.result in {"ok", "catch_all"}` |
   | `all-emails` | `email` is non-empty (any result, including missing verification) |
   | `with-phone` | place `phone` or `companyPhoneNumber` is non-empty (regardless of email) |
   | `everything` | no filter |

3. **Dedupe:** for scopes that produce email rows, group by `email.toLowerCase()`. Keep the first occurrence. If the same email comes from multiple sources, concatenate `Source Query or URL` with ` | `. For `with-phone` rows that have no email, dedupe by lowercased phone instead.
4. **Empty-result rows:** if the **department filter** (anchor #3) produced zero leads for a given domain, include one row for that domain with blank `Email` and `Email Verification Status = "no leads matched filter"`. These rows are inserted before result-scope filtering and are always shown — they tell the user the filter narrowed too much.
5. **Invalid-URL rows (URL-list route only):** include one row per pre-skipped URL with `Email Verification Status = "skipped — invalid URL"`. Also always shown.

State both the active result scope **and** the spurious-match drop count in the deliverable header.

## Rendering

Post-process the raw dataset (from `get-dataset-items` or the script's output file) the same way regardless of route:

1. Flatten `leadsEnrichment` so each lead becomes its own row.
2. Apply both filters (spurious-match, then scope).
3. Dedupe.
4. Project to the 16 columns above (CSV) or to a `contacts` array (JSON).

**CSV deliverable** — one header row, one row per lead, trailing `RUN_METADATA` row: `RUN_METADATA, runId=..., datasetId=..., actor=..., finishedAt=...`.

**JSON deliverable** — envelope:
```json
{
  "runMetadata": {"runId": "...", "datasetId": "...", "actor": "...", "finishedAt": "...", "consoleUrl": "https://console.apify.com/actors/runs/..."},
  "filter": {"scope": "verified-only", "rowKeepLogic": "emailVerification.result == 'ok'"},
  "contacts": [ /* ... */ ]
}
```

**Sidecar** — always write `run_metadata.json` next to the deliverable with the same `runMetadata` fields plus `stats` (placesScraped, rawLeads, spuriousMatchesDropped, keptUnderScope). For multi-route deliverables, `actor` / `runId` / `datasetId` become arrays in matching order.

---

## Supporting file: reference/scripts/package.json

{
  "type": "module"
}

---

## Supporting file: reference/troubleshooting.md

# Troubleshooting

## Auth / setup

- **`APIFY_TOKEN not found`** (script path only) — Create `.env` with `APIFY_TOKEN=...`; get the token from `https://console.apify.com/account/integrations`. Not needed on the MCP path.
- **`Actor not found`** — Confirm the ID is one of `compass/crawler-google-places`, `apify/google-search-scraper`, `vdrmota/contact-info-scraper`. The script converts `/` to `~` when calling the API; expected.

## Run-time

- **`Run FAILED`** — Open the console URL printed by the runner and read the Actor log. Most common cause: malformed input JSON.
- **Timeout** — Leads enrichment adds 30–90 s per domain on top of the base scrape. Raise the timeout (try 1500–1800 s). If the run is still progressing, the dataset already has partial results — pull by datasetId.
- **`Run TIMED-OUT` from Apify** — Lower source breadth (`maxCrawledPlacesPerSearch`, `maxRequestsPerStartUrl`, fewer queries).

## Empty / weak results

- **No verified rows after filter** — Re-render under `verified-plus-catchall` or `all-emails` scope. Catch-all SMTP servers can't be proven but often deliver.
- **No leads at all** — Try in order: bump `maximumLeadsEnrichmentRecords`; widen `leadsEnrichmentDepartments` to `[]`; confirm sources have reachable websites (Maps places without a `website` can't be enriched).
- **All leads dropped by spurious-match filter** — The enrichment service returned only global-fallback leads. There's no real fix — the source domain has no recognizable LinkedIn footprint. Surface the count and move on.
- **Empty SERP queries** — Confirm the keyword is non-empty and ≤ 32 words. Strip stray quotes.

## URL-list route

- **Blocked domain** — Set `useBrowser: true` in `vdrmota/contact-info-scraper` input. Raises cost but unblocks most anti-bot sites.
- **Invalid URL** — Pre-validate in Step 3; emit a `skipped — invalid URL` row. Never submit a bad URL to the Actor.

## Routing ambiguity

Ask one follow-up. Common patterns: SERP+URL-list pasted together → pick one or both; industry + no source → Maps or SERP?; industry + city without "Maps" → confirm route.

## Cost surprises

Pull the breakdown from the run console. Usual causes: `maximumLeadsEnrichmentRecords` too high, source breadth uncapped, `useBrowser: true` left on. Live rates are in the Apify console under the Actor's pricing tab; the 200-lead pre-submit warning rule lives in SKILL.md Step 3.
