
Apify Verified Email Finder
- 162 installs
- 239 repo stars
- Updated June 29, 2026
- apify/awesome-skills
apify-verified-email-finder is a Claude skill that builds verified business email lists from Google Maps, Google SERPs, or a URL list using Apify Actors with in-run verification.
About
This skill builds a list of verified business emails from Google Maps listings, Google SERP results, or a user-supplied URL list. Verification and leads enrichment run inside the same Apify Actor run, so no separate Hunter or NeverBounce step is needed. A developer uses it to build a cold-outreach leads list, positioned as an Apollo or Hunter alternative.
- Builds verified business email lists from Google Maps, Google SERPs, or a URL list
- Verification runs inside the same Apify run, no third-party verifier needed
- Routes to the correct actor by input type and applies a result-scope filter
Apify Verified Email Finder by the numbers
- 162 all-time installs (skills.sh)
- Ranked #376 of 853 Sales & Marketing skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
apify-verified-email-finder capabilities & compatibility
Requires an Apify account (via MCP or APIFY_TOKEN); actor runs and leads-enrichment add-ons consume Apify credits.
- Capabilities
- email finder · lead generation · web scraping
- Use cases
- email · marketing · web scraping · research
- Runs
- Local or remote
- Pricing
- Bring your own API key
What apify-verified-email-finder says it does
Verification happens inside the same Apify run — no third-party verifier needed.
No third-party verifier (Hunter, NeverBounce, Apollo) needed — verification happens inside the same Actor run.
npx skills add https://github.com/apify/awesome-skills --skill apify-verified-email-finderAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 162 |
|---|---|
| repo stars | ★ 239 |
| Last updated | June 29, 2026 |
| Repository | apify/awesome-skills ↗ |
What it does
Build a list of verified business emails from Maps, SERP, or a URL list in one Apify run.
Who is it for?
Building a verified cold-outreach leads list from Maps, SERP, or a URL list.
Skip if: Tasks that do not involve finding or verifying business emails.
When should I use this skill?
The user asks to find verified emails, build a leads list, scrape emails from Maps or SERP, or verify emails for a URL list.
What you get
A deduped, verification-filtered list of business emails in CSV or JSON.
- Deduped verified-email list in CSV or JSON
By the numbers
- 3 routing actors
- 6 required anchor inputs
- 5 result-scope filter options
Files
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:
.envfile withAPIFY_TOKEN- Node.js 20.6+ (for native
--env-filesupport)
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 renderStep 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 withemailVerification.result == "ok". Safest for cold email.verified-plus-catchall—okpluscatch_all. Catch-all is often deliverable but unprovable.all-emails— any lead with a non-emptyemail, 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.
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 3callOptions:{"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):
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 900Use --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-itemswith thedatasetIdfrom Step 4. Use thefieldsparameter (e.g.,title,searchString,countryCode,city,address,phone,website,leadsEnrichment) andclean: trueto keep the response small. For datasets that still exceed the response cap, fetch directly viacurl https://api.apify.com/v2/datasets/<id>/items?fields=...&clean=trueand pipe throughjq. - 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.orgwas attributed to 8 unrelated Polish zoos because the matcher latched onto thezoosubstring). Drop any lead whosecompanyWebsitehostname doesn't equal the source URL's hostname (striphttps?://, leadingwww., anything after/; lowercase). Count drops inrun_metadata.jsonand 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 URLif the same email appears from multiple sources. Forwith-phonerows 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 = ""andEmail 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.
Worked Examples
- Maps: examples/example-maps-input.md
- SERP: examples/example-search-input.md
- URL list: examples/example-url-list-input.md
Quality Rules (always enforce)
- Guard rail: never submit a run with
verifyLeadsEnrichmentEmails: false. - Provenance & traceability: populate the
Sourcecolumn on every row; carry ApifyrunId+datasetIdinrun_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.
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
{
"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 | 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.
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
{
"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)
{
"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.
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)
{
"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 | 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.
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:
{
"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:
{
"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:
{
"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
}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 `\ |
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:
departmentsis a plural array on the lead (not a string field calleddepartment). 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:
{
"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.
{
"type": "module"
}
#!/usr/bin/env node
/**
* Apify Actor Runner - Runs Apify actors and exports results.
*
* Usage:
* # Quick answer (display in chat, no file saved)
* node --env-file=.env scripts/run_actor.js --actor ACTOR_ID --input '{}'
*
* # Export to file
* node --env-file=.env scripts/run_actor.js --actor ACTOR_ID --input '{}' --output data.csv --format csv
*/
import { parseArgs } from 'node:util';
import { writeFileSync, statSync } from 'node:fs';
// User-Agent for tracking skill usage in Apify analytics
const USER_AGENT = 'apify-awesome-skills/apify-verified-email-finder-1.0.0';
// Parse command-line arguments
function parseCliArgs() {
const options = {
actor: { type: 'string', short: 'a' },
input: { type: 'string', short: 'i' },
output: { type: 'string', short: 'o' },
format: { type: 'string', short: 'f', default: 'csv' },
timeout: { type: 'string', short: 't', default: '600' },
'poll-interval': { type: 'string', default: '5' },
help: { type: 'boolean', short: 'h' },
};
const { values } = parseArgs({ options, allowPositionals: false });
if (values.help) {
printHelp();
process.exit(0);
}
if (!values.actor) {
console.error('Error: --actor is required');
printHelp();
process.exit(1);
}
if (!values.input) {
console.error('Error: --input is required');
printHelp();
process.exit(1);
}
return {
actor: values.actor,
input: values.input,
output: values.output,
format: values.format || 'csv',
timeout: parseInt(values.timeout, 10),
pollInterval: parseInt(values['poll-interval'], 10),
};
}
function printHelp() {
console.log(`
Apify Actor Runner - Run Apify actors and export results
Usage:
node --env-file=.env scripts/run_actor.js --actor ACTOR_ID --input '{}'
Options:
--actor, -a Actor ID (e.g., apify/e-commerce-scraping-tool) [required]
--input, -i Actor input as JSON string [required]
--output, -o Output file path (optional - if not provided, displays quick answer)
--format, -f Output format: csv, json (default: csv)
--timeout, -t Max wait time in seconds (default: 600)
--poll-interval Seconds between status checks (default: 5)
--help, -h Show this help message
Output Formats:
JSON (all data) --output file.json --format json
CSV (all data) --output file.csv --format csv
Quick answer (no --output) - displays top 5 in chat
Examples:
# Quick answer - display top 5 products
node --env-file=.env scripts/run_actor.js \\
--actor "apify/e-commerce-scraping-tool" \\
--input '{"keyword": "bluetooth headphones", "marketplaces": ["www.amazon.com"], "maxProductResults": 10}'
# Export prices to CSV
node --env-file=.env scripts/run_actor.js \\
--actor "apify/e-commerce-scraping-tool" \\
--input '{"detailsUrls": ["https://amazon.com/dp/B09V3KXJPB"]}' \\
--output prices.csv --format csv
# Export reviews to JSON
node --env-file=.env scripts/run_actor.js \\
--actor "apify/e-commerce-scraping-tool" \\
--input '{"reviewListingUrls": ["https://amazon.com/dp/B09V3KXJPB"], "maxReviewResults": 100}' \\
--output reviews.json --format json
`);
}
// Start an actor run and return { runId, datasetId }
async function startActor(token, actorId, inputJson) {
// Convert "author/actor" format to "author~actor" for API compatibility
const apiActorId = actorId.replace('/', '~');
const url = `https://api.apify.com/v2/acts/${apiActorId}/runs?token=${encodeURIComponent(token)}`;
let data;
try {
data = JSON.parse(inputJson);
} catch (e) {
console.error(`Error: Invalid JSON input: ${e.message}`);
process.exit(1);
}
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': `${USER_AGENT}/start_actor`,
},
body: JSON.stringify(data),
});
if (response.status === 404) {
console.error(`Error: Actor '${actorId}' not found`);
process.exit(1);
}
if (!response.ok) {
const text = await response.text();
console.error(`Error: API request failed (${response.status}): ${text}`);
process.exit(1);
}
const result = await response.json();
return {
runId: result.data.id,
datasetId: result.data.defaultDatasetId,
};
}
// Poll run status until complete or timeout
async function pollUntilComplete(token, runId, timeout, interval) {
const url = `https://api.apify.com/v2/actor-runs/${runId}?token=${encodeURIComponent(token)}`;
const startTime = Date.now();
let lastStatus = null;
while (true) {
const response = await fetch(url);
if (!response.ok) {
const text = await response.text();
console.error(`Error: Failed to get run status: ${text}`);
process.exit(1);
}
const result = await response.json();
const status = result.data.status;
// Only print when status changes
if (status !== lastStatus) {
console.log(`Status: ${status}`);
lastStatus = status;
}
if (['SUCCEEDED', 'FAILED', 'ABORTED', 'TIMED-OUT'].includes(status)) {
return status;
}
const elapsed = (Date.now() - startTime) / 1000;
if (elapsed > timeout) {
console.error(`Warning: Timeout after ${timeout}s, actor still running`);
return 'TIMED-OUT';
}
await sleep(interval * 1000);
}
}
// Download dataset items
async function downloadResults(token, datasetId, outputPath, format) {
const url = `https://api.apify.com/v2/datasets/${datasetId}/items?token=${encodeURIComponent(token)}&format=json`;
const response = await fetch(url, {
headers: {
'User-Agent': `${USER_AGENT}/download_${format}`,
},
});
if (!response.ok) {
const text = await response.text();
console.error(`Error: Failed to download results: ${text}`);
process.exit(1);
}
const data = await response.json();
if (format === 'json') {
writeFileSync(outputPath, JSON.stringify(data, null, 2));
} else {
// CSV output
if (data.length > 0) {
const fieldnames = Object.keys(data[0]);
const csvLines = [fieldnames.join(',')];
for (const row of data) {
const values = fieldnames.map((key) => {
let value = row[key];
// Truncate long text fields
if (typeof value === 'string' && value.length > 200) {
value = value.slice(0, 200) + '...';
} else if (Array.isArray(value) || (typeof value === 'object' && value !== null)) {
value = JSON.stringify(value) || '';
}
// CSV escape: wrap in quotes if contains comma, quote, or newline
if (value === null || value === undefined) {
return '';
}
const strValue = String(value);
if (strValue.includes(',') || strValue.includes('"') || strValue.includes('\n')) {
return `"${strValue.replace(/"/g, '""')}"`;
}
return strValue;
});
csvLines.push(values.join(','));
}
writeFileSync(outputPath, csvLines.join('\n'));
} else {
writeFileSync(outputPath, '');
}
}
console.log(`Saved to: ${outputPath}`);
}
// Display top 5 results in chat format
async function displayQuickAnswer(token, datasetId) {
const url = `https://api.apify.com/v2/datasets/${datasetId}/items?token=${encodeURIComponent(token)}&format=json`;
const response = await fetch(url, {
headers: {
'User-Agent': `${USER_AGENT}/quick_answer`,
},
});
if (!response.ok) {
const text = await response.text();
console.error(`Error: Failed to download results: ${text}`);
process.exit(1);
}
const data = await response.json();
const total = data.length;
if (total === 0) {
console.log('\nNo results found.');
return;
}
// Display top 5
console.log(`\n${'='.repeat(60)}`);
console.log(`TOP 5 RESULTS (of ${total} total)`);
console.log('='.repeat(60));
for (let i = 0; i < Math.min(5, data.length); i++) {
const item = data[i];
console.log(`\n--- Result ${i + 1} ---`);
for (const [key, value] of Object.entries(item)) {
let displayValue = value;
// Truncate long values
if (typeof value === 'string' && value.length > 100) {
displayValue = value.slice(0, 100) + '...';
} else if (Array.isArray(value) || (typeof value === 'object' && value !== null)) {
const jsonStr = JSON.stringify(value);
displayValue = jsonStr.length > 100 ? jsonStr.slice(0, 100) + '...' : jsonStr;
}
console.log(` ${key}: ${displayValue}`);
}
}
console.log(`\n${'='.repeat(60)}`);
if (total > 5) {
console.log(`Showing 5 of ${total} results.`);
}
console.log(`Full data available at: https://console.apify.com/storage/datasets/${datasetId}`);
console.log('='.repeat(60));
}
// Report summary of downloaded data
function reportSummary(outputPath, format) {
const stats = statSync(outputPath);
const size = stats.size;
let count;
try {
const content = require('fs').readFileSync(outputPath, 'utf-8');
if (format === 'json') {
const data = JSON.parse(content);
count = Array.isArray(data) ? data.length : 1;
} else {
// CSV - count lines minus header
const lines = content.split('\n').filter((line) => line.trim());
count = Math.max(0, lines.length - 1);
}
} catch {
count = 'unknown';
}
console.log(`Records: ${count}`);
console.log(`Size: ${size.toLocaleString()} bytes`);
}
// Helper: sleep for ms
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Main function
async function main() {
// Parse args first so --help works without token
const args = parseCliArgs();
// Check for APIFY_TOKEN
const token = process.env.APIFY_TOKEN;
if (!token) {
console.error('Error: APIFY_TOKEN not found in .env file');
console.error('');
console.error('Add your token to .env file:');
console.error(' APIFY_TOKEN=your_token_here');
console.error('');
console.error('Get your token: https://console.apify.com/account/integrations');
process.exit(1);
}
// Start the actor run
console.log(`Starting actor: ${args.actor}`);
const { runId, datasetId } = await startActor(token, args.actor, args.input);
console.log(`Run ID: ${runId}`);
console.log(`Dataset ID: ${datasetId}`);
// Poll for completion
const status = await pollUntilComplete(token, runId, args.timeout, args.pollInterval);
if (status !== 'SUCCEEDED') {
console.error(`Error: Actor run ${status}`);
console.error(`Details: https://console.apify.com/actors/runs/${runId}`);
process.exit(1);
}
// Determine output mode
if (args.output) {
// File output mode
await downloadResults(token, datasetId, args.output, args.format);
reportSummary(args.output, args.format);
} else {
// Quick answer mode - display in chat
await displayQuickAnswer(token, datasetId);
}
}
main().catch((err) => {
console.error(`Error: ${err.message}`);
process.exit(1);
});
Troubleshooting
Auth / setup
- `APIFY_TOKEN not found` (script path only) — Create
.envwithAPIFY_TOKEN=...; get the token fromhttps://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-catchallorall-emailsscope. Catch-all SMTP servers can't be proven but often deliver. - No leads at all — Try in order: bump
maximumLeadsEnrichmentRecords; widenleadsEnrichmentDepartmentsto[]; confirm sources have reachable websites (Maps places without awebsitecan'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: trueinvdrmota/contact-info-scraperinput. Raises cost but unblocks most anti-bot sites. - Invalid URL — Pre-validate in Step 3; emit a
skipped — invalid URLrow. 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.
Related skills
FAQ
Does it need a separate email verifier?
No. Verification happens inside the same Apify Actor run, so no Hunter, NeverBounce, or Apollo verifier is needed.
Which actor does it use?
It routes by input: compass/crawler-google-places for Maps, apify/google-search-scraper for SERP keywords, and vdrmota/contact-info-scraper for a URL list.