Website Contact Scraper: Emails, Phone Numbers & Social Links
Pricing
from $20.00 / 1,000 results
Website Contact Scraper: Emails, Phone Numbers & Social Links
Crawl any website and extract emails, phone numbers, and social media links. Auto-discovers contact, about, and team pages. One clean record per domain. JS-rendered pages supported.
Pricing
from $20.00 / 1,000 results
Rating
0.0
(0)
Developer
Techforce Global
Maintained by CommunityActor stats
1
Bookmarked
17
Total users
3
Monthly active users
5 days ago
Last modified
Categories
Share
Website Contact Scraper — Emails, Phone Numbers & Social Links from Any List of Websites
Point it at a list of websites and get one consolidated contact record per domain — every email address, every phone number normalised to E.164, and social profiles across 9 platforms — with the contact/about/team pages discovered automatically. Results can go straight into Airtable, Notion, Slack, Sheets or your CRM through an MCP connector.
⚡ Fetches every site over plain HTTP first and only spends a browser on the pages that actually need one. Emails,
tel:links and social URLs are in the served HTML on most sites, so rendering everything in Chromium was paying browser prices for HTTP work. Combined with footer-first link scoring and an early exit once a domain has yielded enough, a typical site now costs 1–2 HTTP fetches instead of up to 10 browser page loads. See Cost Control.
One record per domain, at HTTP prices
| Feature / Capability | Typical contact scraper | This Actor |
|---|---|---|
| Engine | ⚠️ Either always-Cheerio (misses JS sites) or always-browser (pays for every page) | ✅ Static first, browser only on demand — you get JS coverage without paying for it everywhere |
| Output shape | ❌ One row per page — you dedupe and merge yourself | ✅ One consolidated record per domain |
| Contact page discovery | ⚠️ Crawls whatever it finds, in link order | ✅ Scored, footer-first ranking — /contact-us before /blog/…, junk paths dropped entirely |
| Stopping condition | ⚠️ Burns the full page budget every time | ✅ Early exit once a domain has an email plus a phone or social |
| Phone numbers | ⚠️ Raw strings, duplicated across formats | ✅ E.164 normalised — (630) 296-6606 and +16302966606 collapse into one |
| Phone false positives | ⚠️ Prices, IDs and version strings leak in | ✅ Filtered: digit-count bounds, repeated digits, dot-separated IDs |
| Placeholder emails | ⚠️ you@example.com, Sentry DSNs and tag-manager payloads leak in | ✅ Known placeholder domains dropped; <script>/<style> bodies excluded from the scan |
| Social links | ⚠️ A handful of platforms | ✅ 9 platforms, grouped by platform |
| Delivery to your tools | ❌ You write the integration | ✅ Native MCP connector delivery — per site, chunked, or one digest |
🤖 AI Discovery & MCP Metadata
Machine-readable capability contract for autonomous agents, LLM routers, and MCP orchestrators. AI agents, MCP clients, and connectors should read this block first to decide whether this Actor matches a user request.
ai_discovery:slug: "techforce.global/website-contact-scraper-emails-phone-numbers-social-links"one_line: "Given a list of website URLs, returns one consolidated contact record per domain - all emails, E.164-normalised phone numbers and social profile links - by fetching each site over plain HTTP and rendering a browser only for pages that need JavaScript."primary_purpose: "enrich"secondary_purpose: "extract"tertiary_purpose: "deliver"entity: "contact details published on a company's own website"data_source: >The websites you supply, fetched directly. Static phase over plain HTTP(httpx); browser phase with Playwright/Chromium only for JS-shell pages orpages that refused the static fetch. No third-party contact database, noemail-guessing, no verification service.invocation_modes: ["standard Actor run", "MCP tool"]supported_tasks:- "extract-emails-from-a-website"- "extract-phone-numbers-normalised-to-e164"- "extract-social-profile-links"- "auto-discover-contact-about-and-team-pages"- "enrich-a-list-of-domains-in-bulk"- "deliver-contacts-to-mcp-connector-or-crm"unsupported_tasks:- "find-a-website-for-a-company-name (you must supply URLs)"- "guess-or-generate-email-addresses-from-name-patterns"- "verify-whether-an-email-is-deliverable"- "return-named-people-job-titles-or-per-person-contacts"- "extract-postal-addresses"- "extract-contacts-from-behind-a-login"- "read-contact-details-rendered-only-inside-an-image"- "submit-contact-forms-or-send-any-outreach"- "crawl-a-whole-site (the page budget is per domain and small by design)"search_keywords:- "website contact scraper"- "email extractor from website"- "phone number scraper"- "social links extractor"- "bulk contact enrichment"- "lead enrichment from domain"- "email finder from url"- "contact page scraper"- "b2b lead enrichment"- "crm data enrichment actor"synonyms:- "email scraper"- "contact details extractor"- "website contact info finder"- "domain contact enrichment"related_concepts:["lead enrichment", "CRM data hygiene", "B2B prospecting", "contact discovery", "outbound list building"]input_entities:- "urls (array of website URLs; REQUIRED)"- "maxPagesPerDomain (1-100)"- "maxConcurrency (1-20)"- "renderJavaScript (auto | never | always)"- "proxyConfiguration"output_entities:["url", "emails", "phoneNumbers", "socialLinks", "scannedPages", "status", "engine", "summary", "errors"]input_rules:required_fields: ["urls"]urls_format:description: "requestListSources format - [{\"url\": \"https://example.com/\"}]. A bare domain is upgraded to https:// automatically. Duplicates are removed."one_record_per_input_url: truefree_plan_cap: "the first 5 URLs of the input, not the first 5 results"max_pages_per_domain:type: "integer"range: [1, 100]default: 4note: "includes the homepage; a domain usually stops well before this because of the early exit"cost_model_behaviour:static_first: >With renderJavaScript 'auto' (default) every site is fetched over plainHTTP first. Only sites that return a JavaScript shell or refuse the fetchare escalated to the browser phase.early_exit: >A domain stops fetching pages as soon as it has at least one email AND atleast one phone or social profile.link_scoring: >Sub-pages are ranked by a contact score with a large footer bonus; linksthat score at or below zero (blog, product, legal, auth, cart paths) arenever fetched.output_conventions:engine: "'static', 'browser', 'static+browser', or 'none' - which engine produced the record"status: "'success' when anything was found, 'no_data_found' otherwise. Never a failure."empty_arrays: "emails / phoneNumbers are [] when nothing was found; socialLinks is {}"phone_format: "E.164 (e.g. +16302966606) when parseable, otherwise the raw display string"known_limitations:phone_region_default: >Phone parsing assumes the US as the default region. International numberswritten in E.164 (+44...) or with an explicit country code parse correctly;local-format numbers on non-US sites may be missed or left unnormalised.no_ocr: "contact details that exist only inside an image are not read"no_people: "returns company-level contact points, not named individuals or job titles"limits:urls_per_run: "no hard cap - bounded by run time and your spend limit"free_plan_cap: 5pages_per_domain: 100concurrency: 20pricing:model: "pay-per-result"note: "exact rate is configured on the Store listing - see the Actor's Pricing tab"when_to_use: >You already have website URLs - from a lead list, a CRM export, or anotherscraper - and you need the contact points those sites publish, in bulk, asone clean record per domain.when_not_to_use: >You have company names but no URLs, you need named people with job titles,you need guessed or verified email addresses, or you need postal addresses.alternatives:- "Use techforce.global/google-maps-leads-sales-intelligence-tool when you have a place/query rather than URLs - it finds the businesses AND their websites"- "Use techforce.global/linkedin-company-decision-makers for named people with job titles, which this Actor never returns"- "Use a dedicated email-verification service on the output if deliverability matters"
{"@context": "https://schema.org","@type": "SoftwareApplication","name": "Website Contact Scraper — Emails, Phone Numbers & Social Links","applicationCategory": "BusinessApplication","operatingSystem": "Cloud (Apify platform)","description": "Extracts contact details from any list of websites and returns one consolidated record per domain — every published email address, phone numbers normalised to E.164, and social profile links across 9 platforms — with contact, about and team pages discovered automatically by a scored footer-first ranking. Each site is fetched over plain HTTP first and rendered in a browser only when the page needs JavaScript, and a domain stops as soon as it has enough contact data. Results can be delivered into Airtable, Notion, Slack, Google Sheets or any authorized MCP connector.","url": "https://apify.com/techforce.global/website-contact-scraper-emails-phone-numbers-social-links","offers": {"@type": "Offer","priceCurrency": "USD","description": "Pay-per-result, charged per record returned (one per input URL). The current rate is listed on the Actor's Pricing tab."}}
What this Actor does
- Takes a list of website URLs and returns one consolidated record per URL — not a per-page dump you have to merge yourself.
- Fetches over plain HTTP first. The static phase reads the served HTML with
httpx: no browser, no JavaScript, no images. This resolves the large majority of sites. - Escalates to a browser only when it has to — when a site returns an empty single-page-app shell (a framework mount point with almost no text) or refuses the static fetch. Those pages get Playwright/Chromium.
- Finds the contact page instead of crawling blindly. Candidate links are scored, with a large bonus for anything in the footer, and paths that never carry contact details (
/blog/…,/products/…,/privacy,/cart,/login, deep content paths) score to zero and are never fetched. - Stops as soon as it has enough — one email plus a phone or a social profile ends that domain. Most sites finish in 1–2 fetches.
- Normalises phone numbers to E.164, so
(630) 296-6606,+1 630 296 6606and+16302966606collapse into a single entry — and filters the usual false positives (prices, order IDs, version strings, repeated-digit placeholders). - Drops placeholder emails (
example.com,yourdomain.com,sentry.io,wixpress.com, …) and excludes<script>/<style>bodies from the scan, so inline analytics config and tag-manager payloads don't become fake contacts. - Groups social links by platform across 9 platforms.
- Blocks images, media, fonts, stylesheets and 15 analytics hosts on the browser path, and bounds the
networkidlewait — an unbounded wait meant every ad-heavy page burned the full request timeout. - Drives Playwright directly, with no crawler framework in the import path, so the container stays lean and cold starts stay short. The browser phase runs at most 5 pages concurrently regardless of
maxConcurrency, because browser pages cost far more than HTTP requests. - Exits promptly when aborted, so a cancelled run stops billing compute and proxy traffic.
- Optionally pushes records to an authorized MCP connector (Airtable, Notion, Slack, Sheets, HubSpot, or any MCP-compatible connector) per site, chunked, or as one digest. The dataset is always saved regardless of delivery settings.
ℹ️ Company-level contacts, not people. The output is what a company publishes on its own site —
info@,sales@, a switchboard number, social profiles. It does not return named individuals or job titles. For named decision makers, pair with LinkedIn Decision Makers.
When to use it — and when not to
| Scenario | Fit |
|---|---|
| Enriching a list of domains with contact details, in bulk | ✅ Strong (the core use case) |
| Adding emails and phones to a CRM export that only has websites | ✅ Strong (deliveryMode: "perSite") |
| Finding the right inbox to route an enquiry to | ✅ Strong |
| Collecting a company's social profiles across 9 platforms | ✅ Strong |
| Deduplicating phone numbers written in mixed formats | ✅ Strong — E.164 normalisation |
| Cheap first-pass enrichment over thousands of domains | ✅ Strong — renderJavaScript: "never" |
| Contact details on a JavaScript-only site | ✅ Handled — auto escalates it to a browser |
| You have company names, not URLs | ❌ Supply URLs — or use Google Maps Business Leads to find them first |
| Named people with job titles | ❌ Company-level only — use LinkedIn Decision Makers |
Guessed emails (firstname.lastname@…) | ❌ Only addresses actually published on the site |
| Verifying an email is deliverable | ❌ Extraction only — pair with a verification service |
| Postal addresses | ❌ Not extracted |
| Contacts behind a login | ❌ No authentication support |
| Contact details rendered inside an image | ❌ No OCR |
| Submitting contact forms / sending outreach | ❌ Read-only |
| Crawling an entire site | ⚠️ Not the goal — the per-domain page budget is small by design |
| Local-format phone numbers on non-US sites | ⚠️ Parsing assumes US as the default region — see Known limitations |
⚡ Execution Rules (Avoid Common Agent Failures)
urlsis required and usesrequestListSourcesformat —[{"url": "https://example.com/"}]. A bare domain (example.com) is upgraded tohttps://automatically, and duplicates are removed.- You get one record per input URL, always — including sites where nothing was found. Those come back with
status: "no_data_found"and empty arrays, not as errors. statusis never a failure signal. It is"success"or"no_data_found". Readsummary.emailCount/phoneCount/socialPlatformsfor what was actually found.- Free Apify plans process the first 5 URLs of the input — a cap on inputs, not results. That's a successful run.
maxPagesPerDomaindefaults to 4, not 10. It includes the homepage, and most domains stop earlier because of the early exit. Raise it only for sites that bury contact details deep in the navigation.- A domain stops early once it has an email plus a phone or social profile. So
summary.pagesScannedis usually 1–2 even with a budget of 4 — that's the cost optimisation working, not a truncated crawl. renderJavaScriptdefaults toauto. Static first, browser only for JS shells and refused fetches. Useneverfor the cheapest possible bulk pass (returns nothing for JS-only sites) andalwaysonly ifautodemonstrably misses data.- Check
engineto see what a record cost.static= no browser was used.browser/static+browser= that site needed rendering. - Phone numbers are E.164 when parseable (
+16302966606), otherwise the raw display string. Don't assume a leading+on every entry. - Phone parsing assumes the US as the default region.
+44…and other explicit country codes are fine; a UK or German number written in local format may be missed or left unnormalised. socialLinksis an object of arrays, keyed by platform ({"linkedin": ["https://…"]}) — not a flat list. It is{}when nothing was found.emailscan contain several addresses and they are not ranked. There is no "primary email" field; if you need one, take the first or apply your own preference forinfo@/sales@/contact@.- Nothing here is verified. These are addresses published on the page. Some will be stale, role-based, or monitored by nobody.
- No named people, no job titles, no postal addresses. Nine output fields, all company-level.
deliveryModedefaults toperSite, notnone. With a connector selected, a 500-URL run makes 500 connector calls. Usechunkedorsummaryfor a digest.errorsappears only when something went wrong for that domain, capped at 10 entries. It's diagnostic, not a failure — a site can have errors and still return contacts.
dependencies:- rule: "urls is required, in requestListSources format"on_violation: "the run raises 'Input must include at least one URL in the urls array.'"- rule: "one record per input URL, including empty ones"note: "status 'no_data_found' with empty arrays is a valid, successful result"- rule: "free Apify plans process the first 5 URLs of the input"on_violation: "silently truncated input read as a scraping failure"- rule: "maxPagesPerDomain is clamped to [1, 100] and defaults to 4"note: "includes the homepage; the early exit usually ends a domain sooner"- rule: "a domain stops once it has an email plus a phone or social profile"on_violation: "pagesScanned of 1-2 misread as a truncated crawl"- rule: "renderJavaScript 'never' skips the browser entirely"on_violation: "JS-only sites silently return no_data_found - use 'auto' if coverage matters more than cost"- rule: "phone parsing assumes the US default region"on_violation: "local-format non-US numbers missed or left unnormalised"- rule: "socialLinks is an object of arrays keyed by platform, not a list"on_violation: "iterating it yields platform names, not URLs"- rule: "emails are unranked and unverified"on_violation: "treating the first address as a verified primary contact"- rule: "delivery requires mcpConnector AND mcpTool"on_violation: "delivery step is skipped with a warning; the dataset is still written in full"- rule: "mcpArguments must reference {message} for the rendered template to appear anywhere"on_violation: "message body is empty; the tool is still called"- rule: "mcpConnector only resolves when the Actor runs on the Apify platform"on_violation: "local runs log 'APIFY_MCP_PROXY_URL is not set' and skip delivery; the dataset is unaffected"authentication:actor_input: noneplatform: "Apify API token required for API / SDK / MCP invocation; paid plan required for residential proxy groups"target_site: "no credentials of any kind - the Actor only reads publicly served pages"
📥 Input Contract
.actor/input_schema.json is authoritative — if any table on this page disagrees with the schema files, the schema files win.
Parameters
| Field | Type | Required | Default | Allowed Values / Format | Example |
|---|---|---|---|---|---|
urls | array | ✅ Yes | — | requestListSources — [{"url": "https://…"}] | see below |
maxPagesPerDomain | integer | ⬜ No | 4 | 1–100, homepage included | 6 |
maxConcurrency | integer | ⬜ No | 5 | 1–20; the browser phase is additionally capped at 5 | 10 |
renderJavaScript | enum | ⬜ No | auto | auto · never · always | "never" |
proxyConfiguration | object | ⬜ No | Apify Proxy off | Apify proxy editor object | see below |
renderJavaScript — the main cost dial
| Value | Behaviour | Use when |
|---|---|---|
auto (default) | Every site fetched over plain HTTP; only JS shells and refused fetches get a browser | Almost always. Full coverage, browser cost only where it's unavoidable |
never | Static only; JS-only sites return no_data_found | Cheapest possible bulk pass over thousands of domains, or a first sweep before a targeted auto re-run |
always | Every page rendered in a browser (the pre-optimisation behaviour) | Only if auto demonstrably misses data you know is on the page |
Proxy
{"proxyConfiguration": { "useApifyProxy": true, "apifyProxyGroups": ["RESIDENTIAL"] }}
Off by default, and usually unnecessary — these are public marketing pages fetched a couple of times each. The proxy applies to both phases. Enable RESIDENTIAL (paid plans) only if your targets block datacenter IPs.
Social platforms detected
linkedin (company + personal) · facebook · twitter / x · instagram · youtube · tiktok · pinterest · github · whatsapp (wa.me, api.whatsapp.com)
How contact pages are chosen
Sub-page candidates are scored, not just filtered. Footer links get a large bonus, because that is where contact details actually live.
| Signal | Effect |
|---|---|
Link is inside a <footer>, .footer, #footer or role="contentinfo" | +1000 |
Path or anchor text matches contact-us, contact, get-in-touch, enquiry, support, about, team, imprint, impressum, … | +100 down to +84 by specificity |
Path contains blog, news, product, pricing, career, privacy, terms, cart, login, search, tag, feed, … | −80 |
| Path is more than two segments deep | −20 per extra segment |
| Final score ≤ 0 | Never fetched |
So a site whose only links are blog posts and product pages costs exactly one fetch, not four.
Examples
Bulk enrichment — the recommended default
{"urls": [{ "url": "https://www.stripe.com/" },{ "url": "https://www.notion.so/" },{ "url": "https://www.figma.com/" }],"maxPagesPerDomain": 4,"renderJavaScript": "auto","deliveryMode": "none"}
Cheapest possible sweep — thousands of domains, no browser at all
{"urls": [{ "url": "https://example-one.com/" }, { "url": "https://example-two.com/" }],"maxPagesPerDomain": 2,"maxConcurrency": 20,"renderJavaScript": "never","deliveryMode": "none"}
Deep dig — a site that hides contact details several clicks in
{"urls": [{ "url": "https://www.some-large-enterprise.com/" }],"maxPagesPerDomain": 12,"renderJavaScript": "auto","deliveryMode": "none"}
Force rendering — only if auto missed something you can see on the page
{"urls": [{ "url": "https://app-only-spa.example/" }],"maxPagesPerDomain": 4,"renderJavaScript": "always","deliveryMode": "none"}
MCP Delivery Configuration (Optional)
Delivery pushes contact data into your own tools. It never emails or calls anyone.
| Parameter | Type | Default | Description |
|---|---|---|---|
mcpConnector | string | "" | Authorized MCP connector on your Apify account (Airtable, Notion, Slack, Sheets, HubSpot, …). Leave empty for dataset-only runs. |
deliveryMode | enum | perSite | perSite (one call per website) · chunked (split across calls) · summary (one call with everything) · none. |
mcpTool | string | "" | Tool name on the connector: create_record, create_page, send_message, append_row. Run once with a connector selected — the log lists the connector's available tools. |
mcpArguments | object | {} | Arguments passed to the tool. String leaves support {placeholders}. |
mcpMessageTemplate | string | "" | Template rendered and exposed to mcpArguments as {message}. |
Per-site placeholders: {url}, {emails}, {phones}, {socials}, {primaryEmail}, {primaryPhone}, {socialPlatforms}, {emailCount}, {phoneCount}, {socialCount}, {pagesScanned}, {status}, {engine}, {site} (a formatted block), {message}.
Summary / chunked placeholders: {siteCount}, {emailTotal}, {phoneTotal}, {sites}, {part}, {partCount}, {message}.
One Airtable row per enriched website:
{"urls": [{ "url": "https://www.stripe.com/" }],"mcpConnector": "<your-authorized-airtable-connector>","deliveryMode": "perSite","mcpTool": "create_record","mcpArguments": {"fields": {"Website": "{url}","Primary email": "{primaryEmail}","All emails": "{emails}","Primary phone": "{primaryPhone}","Socials": "{socials}","Pages scanned": "{pagesScanned}"}}}
One Slack digest per run:
{"urls": [{ "url": "https://example-one.com/" }, { "url": "https://example-two.com/" }],"mcpConnector": "<your-authorized-slack-connector>","deliveryMode": "summary","mcpTool": "send_message","mcpArguments": { "channel": "#lead-enrichment", "text": "{message}" },"mcpMessageTemplate": "📇 {siteCount} sites enriched — {emailTotal} emails, {phoneTotal} phones:\n\n{sites}"}
A large list split across Notion sub-pages:
{"urls": [{ "url": "https://example-one.com/" }],"mcpConnector": "<your-authorized-notion-connector>","deliveryMode": "chunked","mcpTool": "notion-create-pages","mcpArguments": {"parent": { "page_id": "<your-page-id>" },"pages": [{"properties": { "title": "Enriched contacts — part {part}/{partCount}" },"content": "{sites}"}]}}
One Google Sheets row per website:
{"urls": [{ "url": "https://example-one.com/" }],"mcpConnector": "<your-authorized-sheets-connector>","deliveryMode": "perSite","mcpTool": "append_row","mcpArguments": {"values": ["{url}", "{primaryEmail}", "{primaryPhone}", "{socialPlatforms}", "{status}"]}}
📤 Output Contract & Data Structure
Storage: Apify Dataset — one object per input URL, written after both phases finish.
Pagination: limit & offset on the dataset items endpoint.
Duplicates: input URLs are deduplicated before the run, so one record per distinct URL.
Record fields
| Field | Type | Empty value | Meaning |
|---|---|---|---|
url | string | never empty | The input URL, echoed back — the join key for your own data |
emails | array of string | [] | Every address found, lowercased and deduplicated. Unranked and unverified |
phoneNumbers | array of string | [] | E.164 (+16302966606) when parseable, otherwise the raw display string |
socialLinks | object of arrays | {} | Keyed by platform: {"linkedin": ["https://…"], "twitter": [...]} |
scannedPages | array of string | [] when nothing loaded | Exactly which pages were fetched, in order — the homepage first |
status | string | — | "success" if anything was found, else "no_data_found". Never a failure code |
engine | string | "none" | "static", "browser", "static+browser" — what this record cost |
summary | object | — | emailCount, phoneCount, socialPlatforms, pagesScanned |
errors | array of string | key absent | Present only when a fetch failed for that domain; capped at 10 |
Example — resolved without a browser
{"url": "https://www.acme-widgets.com/","emails": ["hello@acme-widgets.com", "sales@acme-widgets.com"],"phoneNumbers": ["+16302966606"],"socialLinks": {"linkedin": ["https://www.linkedin.com/company/acme-widgets"],"twitter": ["https://x.com/acmewidgets"]},"scannedPages": ["https://www.acme-widgets.com/","https://www.acme-widgets.com/contact-us"],"status": "success","engine": "static","summary": { "emailCount": 2, "phoneCount": 1, "socialPlatforms": 2, "pagesScanned": 2 }}
Example — nothing published
{"url": "https://www.no-contact-details.example/","emails": [],"phoneNumbers": [],"socialLinks": {},"scannedPages": ["https://www.no-contact-details.example/"],"status": "no_data_found","engine": "static","summary": { "emailCount": 0, "phoneCount": 0, "socialPlatforms": 0, "pagesScanned": 1 }}
Example — needed a browser, and one sub-page failed
{"url": "https://spa-only.example/","emails": ["info@spa-only.example"],"phoneNumbers": [],"socialLinks": { "linkedin": ["https://www.linkedin.com/company/spa-only"] },"scannedPages": ["https://spa-only.example/", "https://spa-only.example/contact"],"status": "success","engine": "browser","summary": { "emailCount": 1, "phoneCount": 0, "socialPlatforms": 1, "pagesScanned": 2 },"errors": ["static homepage: timeout"]}
Flattening for a CRM
socialLinks is nested, so pick per platform rather than iterating blindly:
def flatten(record):socials = record.get("socialLinks") or {}emails = record.get("emails") or []phones = record.get("phoneNumbers") or []return {"website": record["url"],"email": emails[0] if emails else "", # unranked - first, not "best""all_emails": "; ".join(emails),"phone": phones[0] if phones else "","linkedin": (socials.get("linkedin") or [""])[0],"twitter": (socials.get("twitter") or [""])[0],"found": record["status"] == "success",}
Prefer role inboxes if you have a preference — the Actor deliberately doesn't guess for you:
PREFERRED = ("contact@", "hello@", "info@", "sales@", "enquiries@", "support@")def best_email(emails):for prefix in PREFERRED:for email in emails:if email.startswith(prefix):return emailreturn emails[0] if emails else ""
What a successful run looks like
{"status": "SUCCEEDED","defaultDatasetId": "<datasetId>","stats": { "itemCount": 250 }}
itemCount equals the number of distinct input URLs processed (capped at 5 on free plans) — not the number of sites where contacts were found. Count status == "success" for that.
The run log closes with the numbers that matter for cost:
Static phase done — 231/250 site(s) resolved without a browser.Browser phase for 19 site(s)...Done — 250 site(s), 412 page(s) fetched, 19 site(s) needed a browser.
▶️ Invocation & Integration
Lifecycle: Discover → Understand → Configure → Execute → Retrieve → Validate. Authenticate every call with Authorization: Bearer <APIFY_TOKEN>.
Apify Console
Open the Actor → Input tab → paste your URLs → leave Max pages per domain at 4 and JavaScript rendering on auto → (optional) pick an MCP connector, tool and template → Start → export from the Dataset tab (JSON, CSV, Excel, XML). The Contacts only dataset view gives you a CRM-ready table.
Model Context Protocol (MCP)
claude mcp add --transport http apify \"https://mcp.apify.com?tools=techforce.global/website-contact-scraper-emails-phone-numbers-social-links"
Python (apify-client)
import osfrom apify_client import ApifyClientclient = ApifyClient(os.getenv("APIFY_TOKEN"))ACTOR = "techforce.global/website-contact-scraper-emails-phone-numbers-social-links"domains = ["https://www.stripe.com/", "https://www.notion.so/", "https://www.figma.com/"]run = client.actor(ACTOR).call(run_input={"urls": [{"url": url} for url in domains],"maxPagesPerDomain": 4,"renderJavaScript": "auto", # static first, browser only where needed"deliveryMode": "none",})items = client.dataset(run["defaultDatasetId"]).list_items().itemsfound = [item for item in items if item["status"] == "success"]print(f"{len(found)}/{len(items)} sites yielded contacts")# `engine` tells you what each record cost.no_browser = sum(1 for item in items if item["engine"] == "static")print(f"{no_browser}/{len(items)} resolved without a browser")for item in found:socials = item.get("socialLinks") or {}print(f'{item["url"]} ({item["summary"]["pagesScanned"]} page(s), {item["engine"]})')print(f' emails : {", ".join(item["emails"]) or "-"}')print(f' phones : {", ".join(item["phoneNumbers"]) or "-"}')print(f' socials: {", ".join(socials) or "-"}')
Two-pass pattern for large lists — cheap sweep, then render only the gaps:
urls = [{"url": url} for url in domains] # e.g. 5,000 domains# Pass 1: static only. No browser cost at all.cheap = client.actor(ACTOR).call(run_input={"urls": urls, "maxPagesPerDomain": 2,"renderJavaScript": "never", "maxConcurrency": 20, "deliveryMode": "none",})rows = client.dataset(cheap["defaultDatasetId"]).list_items().items# Pass 2: only the misses, with rendering allowed.misses = [{"url": row["url"]} for row in rows if row["status"] == "no_data_found"]if misses:rendered = client.actor(ACTOR).call(run_input={"urls": misses, "maxPagesPerDomain": 4,"renderJavaScript": "auto", "deliveryMode": "none",})rows += client.dataset(rendered["defaultDatasetId"]).list_items().items
JavaScript / TypeScript (apify-client)
import { ApifyClient } from 'apify-client';const client = new ApifyClient({ token: process.env.APIFY_TOKEN });const ACTOR = 'techforce.global/website-contact-scraper-emails-phone-numbers-social-links';const run = await client.actor(ACTOR).call({urls: [{ url: 'https://www.stripe.com/' }, { url: 'https://www.notion.so/' }],maxPagesPerDomain: 4,renderJavaScript: 'auto',deliveryMode: 'none',});const { items } = await client.dataset(run.defaultDatasetId).listItems();// socialLinks is an OBJECT of arrays, not a flat list.const rows = items.map((item) => ({website: item.url,email: item.emails?.[0] ?? '',phone: item.phoneNumbers?.[0] ?? '',linkedin: item.socialLinks?.linkedin?.[0] ?? '',found: item.status === 'success',engine: item.engine,}));console.table(rows);
cURL — synchronous run (small lists; 300s limit)
curl -X POST \-H 'Content-Type: application/json' \-H 'Authorization: Bearer <YOUR_APIFY_TOKEN>' \-d '{"urls":[{"url":"https://www.stripe.com/"}],"maxPagesPerDomain":4,"renderJavaScript":"auto","deliveryMode":"none"}' \'https://api.apify.com/v2/acts/techforce.global~website-contact-scraper-emails-phone-numbers-social-links/run-sync-get-dataset-items'
cURL — asynchronous with a spend cap (recommended for bulk)
# 1. Start execution with a hard spend limitcurl -X POST -H 'Content-Type: application/json' \-H 'Authorization: Bearer <YOUR_APIFY_TOKEN>' \-d '{"urls":[{"url":"https://a.example/"},{"url":"https://b.example/"}],"renderJavaScript":"auto","deliveryMode":"none"}' \'https://api.apify.com/v2/acts/techforce.global~website-contact-scraper-emails-phone-numbers-social-links/runs?maxTotalChargeUsd=5.00'# 2. Pollcurl -H 'Authorization: Bearer <YOUR_APIFY_TOKEN>' \'https://api.apify.com/v2/actor-runs/<runId>'# 3. Retrieve when SUCCEEDEDcurl -H 'Authorization: Bearer <YOUR_APIFY_TOKEN>' \'https://api.apify.com/v2/datasets/<DATASET_ID>/items?clean=true&format=json'
⚠️ Autonomous Agent Error Handling & Resolution Matrix
| Error Code | Detection Condition | Underlying Cause | Deterministic Agent Action |
|---|---|---|---|
AUTH_INVALID | HTTP 401 from Apify | Missing or malformed APIFY_TOKEN | Abort & Prompt User: request a valid Apify API token. |
RATE_LIMITED | HTTP 429 from Apify | Apify API rate / concurrency limits | Retry: exponential backoff (2s, 4s, 8s). |
SYNC_TIMEOUT | HTTP 408 on the sync endpoint | Bulk runs exceed the 300s synchronous ceiling | Reconfigure: async POST /runs + polling. |
MISSING_URLS | Run fails: Input must include at least one URL in the "urls" array. | urls empty or absent | Modify Input: supply [{"url": "…"}]. |
URLS_WRONG_SHAPE | No usable URLs found in the "urls" array. | Objects without a url key, or blank strings | Modify Input: use requestListSources shape. |
NO_URL_TO_START_FROM | User has company names, not URLs | This Actor takes URLs only | Re-route: Google Maps Business Leads to find the sites first. |
FREE_PLAN_CAP | Only 5 records for a longer input | Free plans process the first 5 URLs | Prompt User: upgrade for bulk runs. |
NO_DATA_FOUND | status: "no_data_found" | The site publishes no contact details, or hides them | Not an error. Retry once with a higher maxPagesPerDomain; then accept. |
JS_ONLY_SITE_MISSED | no_data_found and renderJavaScript was never | The browser phase was disabled | Reconfigure: re-run those URLs with auto. |
SHALLOW_SCAN_SUSPECTED | pagesScanned is 1–2 with a budget of 4+ | The early exit fired — enough was already found | Not an error. This is the cost optimisation. |
CONTACT_PAGE_NOT_FETCHED | You expected a page that wasn't in scannedPages | Its contact score was ≤ 0, or the budget ran out | Reconfigure: raise maxPagesPerDomain; add the page as its own input URL if it's unusual. |
STATIC_FETCH_BLOCKED | errors contains http_403 / http_429 | The site refused the plain HTTP fetch | Already handled — that URL was escalated to the browser. If it still failed, enable a RESIDENTIAL proxy. |
STATIC_TIMEOUT | errors contains timeout | Slow origin; static budget is 20s | Already handled via browser escalation. Retry if the site is known-slow. |
PHONE_NOT_E164 | A phoneNumbers entry has no + prefix | phonenumbers could not parse it; raw string kept | Fix Code: treat entries as opaque strings; normalise with a region hint of your own. |
PHONE_REGION_MISS | Non-US site returns no phone despite one being visible | Parsing assumes the US default region | Known limitation. Extract from the page text yourself, or open an issue. |
SOCIALS_NOT_A_LIST | Iterating socialLinks yields strings | It's an object of arrays keyed by platform | Fix Code: socialLinks.linkedin?.[0]. |
EMAIL_NOT_RANKED | The "wrong" address used as primary | emails is unranked by design | Fix Code: apply your own contact@/sales@ preference. |
EMAIL_UNVERIFIED | A delivered email bounces | Extraction ≠ verification | Fix Expectation: pair with a verification service. |
NO_PEOPLE_DATA | No names or job titles | Company-level output only | Re-route: LinkedIn Decision Makers. |
NO_POSTAL_ADDRESS | No street address field | Not extracted | Re-route: Google Maps Business Leads. |
IMAGE_ONLY_CONTACT | Contact visible in a browser but not in the output | It's rendered inside an image; no OCR | Fix Expectation: not supported. |
LOGIN_REQUIRED | Target needs authentication | No credential support | Abort & Prompt User: public pages only. |
DELIVERY_SKIPPED | Log no tool name was provided | mcpConnector set but mcpTool empty | Modify Input: set mcpTool; the dataset is already saved. |
DELIVERY_TOOL_UNKNOWN | Log Tool 'x' is not available on this connector | Wrong tool name for that connector | Modify Input: pick a name from the Available tools: list in the same log line. |
DELIVERY_EMPTY | Connector called, body empty | mcpArguments omitted the {message} placeholder | Modify Input: map {message} inside mcpArguments. |
DELIVERY_FLOOD | One connector call per input URL | deliveryMode defaults to perSite | Reconfigure: use chunked or summary. |
MCP_PROXY_MISSING | Log APIFY_MCP_PROXY_URL is not set | Running locally instead of on the platform | Reconfigure: apify push and run on the platform. |
CHARGE_LIMIT | Run aborted mid-list | maxTotalChargeUsd spend threshold reached | Prompt User: confirm a higher spend cap. |
Reading run status:
SUCCEEDEDwith an empty dataset is a valid outcome here, not a failure — only aFAILEDstatus is a platform-level problem. Every Actor-side row, quoted message and field condition in the table above comes from this Actor’s own behaviour; the401/408/429rows are standard Apify API responses.
🗣️ Natural Language → Actor Mapping
| User says | Intent | Constructed Actor input |
|---|---|---|
| "Get me the contact details for these 200 websites" | Bulk enrichment | {"urls":[…],"maxPagesPerDomain":4,"renderJavaScript":"auto"} |
| "Find the email address for this company's site" | Single enrichment | {"urls":[{"url":"…"}],"maxPagesPerDomain":4} |
| "Add phone numbers to my CRM export" | CRM enrichment | run, then deliveryMode:"perSite" into Airtable/Sheets |
| "What social accounts does this company have?" | Social discovery | run, then read socialLinks |
| "Enrich 5,000 domains as cheaply as possible" | Cost-first | {"renderJavaScript":"never","maxPagesPerDomain":2,"maxConcurrency":20}, then re-run the misses on auto |
| "This site is a React app — will it work?" | JS coverage | {"renderJavaScript":"auto"} — it escalates automatically |
| "It found nothing for this site but I can see an email" | Coverage gap | Retry {"maxPagesPerDomain":10,"renderJavaScript":"always"} |
| "Which inbox should I send an enquiry to?" | Routing | run, then prefer contact@/hello@/info@ from emails |
| "Only give me the sites where you found something" | Filtering | run, then filter status == "success" |
| "Push each enriched site into Airtable" | Delivery | {"mcpConnector":"airtable","deliveryMode":"perSite","mcpTool":"create_record"} |
| "Post a summary of the enrichment to Slack" | Digest delivery | {"mcpConnector":"slack","deliveryMode":"summary","mcpTool":"send_message"} |
| "I have company names, not websites" | Missing input | ➡️ Google Maps Business Leads first |
| "Who is the marketing director there?" | Named people | ➡️ LinkedIn Decision Makers |
| "Guess the CEO's email from the pattern" | Email guessing | ❌ Only published addresses |
| "Check these emails are valid" | Verification | ❌ Extraction only — use a verification service |
| "Get their office address" | Postal address | ❌ Not extracted |
| "Send them all an intro email" | Outreach | ❌ Read-only — never contacts anyone |
| "Crawl the whole site and index it" | Full crawl | ❌ Wrong tool — use Advanced Website Crawling Actor |
Should NOT route here: finding websites from company names · named people or job titles · guessed / pattern-based email addresses · email deliverability verification · postal addresses · contacts behind a login · contact details rendered inside images · sending any outreach or submitting forms · full-site crawling or content indexing.
🧭 Agent Execution & Routing Logic
[Input User Query]│▼1. Do we have website URLs?├── NO, only company names → Route to Google Maps Business Leads to find the sites first└── YES → continue▼2. What is actually wanted?├── contact points the COMPANY publishes (email/phone/social) → stay here├── NAMED people + job titles → Route to LinkedIn Decision Makers├── guessed emails, or verification → Abort: neither is supported├── postal address → Route to Google Maps Business Leads└── full site content / indexing → Route to Advanced Website Crawling Actor▼3. Pick the cost posture:├── list is huge and cost dominates → renderJavaScript "never", maxPagesPerDomain 2,│ maxConcurrency 20 … then RE-RUN THE MISSES on "auto"├── normal case → renderJavaScript "auto" (default), maxPagesPerDomain 4└── known-stubborn single site → "always" + maxPagesPerDomain 10-12▼4. Proxy needed? Public marketing pages usually NO.└── targets block datacenter IPs → useApifyProxy + RESIDENTIAL (paid plan)▼5. Deliver to a connector? YES → mcpConnector + mcpTool + {message}│ perSite (1 call per URL!) / chunked / summary│ NO → set deliveryMode "none" (it defaults to perSite)▼6. Bulk list? Use the ASYNC endpoint and set maxTotalChargeUsd▼[Execute]│▼7. Read the records — every input URL returns one, so iterate on `status`:├── status "success" → flatten. socialLinks is an OBJECT of arrays.│ emails are UNRANKED and UNVERIFIED - apply your own│ role-inbox preference; verify before sending anything.└── status "no_data_found" → was renderJavaScript "never"?YES → re-run that URL on "auto"NO → retry once with a higher maxPagesPerDomain,then accept: the site publishes nothing▼8. Sanity-check cost, not just data:`engine` per record and the closing log line("N/M site(s) resolved without a browser") show where the money went.
💰 Cost Control & Pricing Transparency
Pay-per-result: you are charged per record returned — one per input URL — with no monthly subscription. The exact rate is on the Actor's Pricing tab in the Apify Store.
Free Apify plans process the first 5 URLs of the input, so you can validate a configuration for five results before committing to a bulk run.
Autonomous spend caps: pass maxTotalChargeUsd as a query parameter on the run endpoint for a hard per-execution ceiling.
ℹ️ Always confirm the live rate on the Actor’s Apify Store page before committing to a large run. The Store listing is what bills you; the figures above are a worked example at the advertised rate. Apify platform usage is billed separately.
Where platform usage actually goes — and what changed
Apify platform usage (compute units, plus proxy GB if enabled) is billed on top of the per-result rate, and on a contact scraper it used to dominate. Four changes cut it:
| Change | Before | Now |
|---|---|---|
| Engine | Chromium rendered every page of every site | Plain HTTP first; a browser only for JS shells and refused fetches |
networkidle wait | Unbounded — on pages with streaming analytics it never fires, so each one burned the full request timeout | Bounded at 8s and allowed to expire |
| Framework | A crawler framework in the import path, pulling a browser-fingerprint dependency chain | Playwright driven directly — leaner image, shorter cold start |
| Page budget | Default 10 pages per domain, always spent | Default 4, and a domain stops as soon as it has an email plus a phone or social |
| Sub-page choice | Contact pages merely sorted first — blog and product pages still fetched to fill the budget | Scored, footer-first; anything scoring ≤ 0 is never fetched |
| Browser assets | Images, media, fonts, CSS and analytics all downloaded | Aborted, plus 15 analytics/tracking hosts blocked |
In practice a typical site now costs 1–2 plain HTTP fetches where it previously cost up to 10 browser page loads. The run log makes the split explicit:
Static phase done — 231/250 site(s) resolved without a browser.Done — 250 site(s), 412 page(s) fetched, 19 site(s) needed a browser.
Cost levers, cheapest first:
renderJavaScript: "never"— no browser at all. The cheapest possible pass; combine with the two-pass pattern above so you only render the misses.maxPagesPerDomain: 2— homepage plus the single best contact link. Enough for most sites.- Leave
renderJavaScriptonauto. Setting it toalwaysreinstates the old cost profile on every page. - Validate on 5 URLs first — the free-plan cap is a feature here.
- Skip the proxy unless targets actually block you; residential traffic is metered per GB.
- Prefer
chunked/summarydelivery overperSite— 1 connector call instead of one per URL. - Cap the spend with
maxTotalChargeUsdon any agent-driven run.
🔍 Companion machine-readable files
| File | Purpose |
|---|---|
.actor/actor.json | Identity, title, description, version, and the input / output / dataset schema references |
.actor/input_schema.json | Authoritative typed input contract — urls (required), maxPagesPerDomain (1–100, default 4), maxConcurrency (1–20), renderJavaScript enum, proxy, and the 5 delivery fields |
.actor/dataset_schema.json | Two Console Output views — full Results overview (incl. engine) and a CRM-ready Contacts only view |
.actor/output_schema.json | Declares where results are stored |
my_actor/main.py | Input handling, the static phase, browser escalation, resource blocking, early exit, record assembly, MCP delivery |
my_actor/extractor.py | Email / phone / social extraction, strip_tags, JS-shell detection, and the scored footer-first link ranking |
my_actor/delivery.py | MCP delivery: placeholder flattening, site-block rendering, chunk grouping, per-mode dispatch |
my_actor/connector.py | MCP connector session handling through the Apify MCP Proxy |
Note: this Actor has no pay_per_event.json and makes no Actor.charge() calls — billing is configured entirely on the Store listing.
If any table on this page disagrees with the schema files, the schema files win.
🛠️ Troubleshooting
| # | Symptom you see | Most likely cause | Fix |
|---|---|---|---|
| 1 | Run fails immediately with an input error | urls is empty, or the objects have no url key | Use [{"url": "https://…"}]. |
| 2 | Only 5 records from a longer list | Free Apify plan processes the first 5 URLs | Upgrade; the log prints the upgrade banner. |
| 3 | status: "no_data_found" for a site with a visible email | Contact page not reached, or the page needs JS | Raise maxPagesPerDomain; confirm renderJavaScript isn't never. |
| 4 | Lots of no_data_found all at once | renderJavaScript: "never" skipped every JS-only site | Re-run the misses with auto. |
| 5 | pagesScanned is 1–2 though the budget is 4 | The early exit fired — enough was found | Working as designed. Raise the budget only if you want more addresses. |
| 6 | A page you expected isn't in scannedPages | Its contact score was ≤ 0 (blog/product/legal/auth path) | Add that page directly as its own input URL. |
| 7 | errors shows http_403 or http_429 | The site refused the plain HTTP fetch | Already escalated to a browser. If still empty, enable a RESIDENTIAL proxy. |
| 8 | errors shows timeout | Slow origin (static budget is 20s) | Already escalated. Re-run if the site is known-slow. |
| 9 | errors shows content_type:application/pdf | The URL isn't an HTML page | Point at the site's HTML homepage. |
| 10 | A phone number has no + prefix | phonenumbers couldn't parse it, so the raw string was kept | Expected. Treat entries as opaque strings. |
| 11 | A visible non-US phone number is missing | Parsing assumes the US default region | Known limitation — see below. |
| 12 | Same number appears twice | Two formats that don't normalise to the same E.164 | Rare; dedupe on digits your side. |
| 13 | Iterating socialLinks gives platform names | It's an object of arrays | socialLinks.linkedin?.[0]. |
| 14 | An odd email like u0040@… or a Sentry address | Regex extraction from markup isn't perfect | Known placeholder domains are dropped; filter further your side. |
| 15 | The "wrong" email is first | emails is unranked | Apply a role-inbox preference (snippet above). |
| 16 | A delivered email bounced | Extraction ≠ verification | Pair with a verification service. |
| 17 | No names or job titles anywhere | Company-level contacts only | Use LinkedIn Decision Makers. |
| 18 | Run is slower than expected | Many sites escalated to the browser phase | Check the Static phase done — N/M log line; lower maxPagesPerDomain. |
| 19 | One connector call per URL | deliveryMode defaults to perSite | Switch to chunked or summary. |
| 20 | Notion / Airtable timed out | One payload carrying every site is too large | Use deliveryMode: "chunked". |
| 21 | Slack / Airtable received nothing | Delivery needs both mcpConnector and mcpTool | Set both. The dataset is still written in full. |
| 22 | Connector fired but the message body is empty | mcpArguments did not reference {message} | Map {message} in mcpArguments and populate mcpMessageTemplate. |
| 23 | Log APIFY_MCP_PROXY_URL is not set | You ran locally; connectors only resolve on the platform | Deploy with apify push and run on the platform. |
| 24 | HTTP 408 on run-sync-get-dataset-items | The synchronous endpoint has a hard 300-second ceiling | Use async POST /runs → poll → fetch. |
| 25 | HTTP 401 / 403 from Apify | Missing, expired or malformed APIFY_TOKEN | Regenerate in Apify Console → Settings → API & Integrations. |
| 26 | HTTP 429 from Apify | Apify account concurrency / rate limits | Retry with exponential backoff (2s → 4s → 8s). |
Known limitations
- Phone parsing assumes the US as the default region. Numbers in E.164 (
+44 20 …) or with an explicit country code parse and normalise correctly. A UK, German or Indian number written in purely local format may be missed or kept unnormalised. - No OCR. Contact details that exist only inside an image are invisible to this Actor — a deliberate choice by some sites to defeat scrapers.
- Emails are unranked and unverified. Extraction only; no deliverability check, no bounce prediction.
- Company-level, not person-level. No names, no job titles, no per-person addresses.
- Not a site crawler. The per-domain page budget is small on purpose; use Advanced Website Crawling Actor for full-site content.
Diagnostic checklist before opening an issue
- Baseline run. One known-good site,
maxPagesPerDomain: 4,renderJavaScript: "auto",deliveryMode: "none". - Contacts returned? If yes, the pipeline is fine and the earlier site simply publishes less than you expected.
- Check
scannedPages. It tells you exactly which pages were read. If the page holding the email isn't listed, that's a budget or link-scoring issue — raisemaxPagesPerDomain. - Check
engineanderrors.staticwith no errors means the page was read fine and the data genuinely wasn't in the HTML. - Force it.
renderJavaScript: "always"withmaxPagesPerDomain: 10. If that finds the contact andautodidn't, that's worth reporting.
If the issue survives all five steps, open an Issues ticket on the Actor page (or email support) with the run ID, the exact input JSON, and the URL where you can see the contact detail the Actor missed.
❓ FAQ
Setup & scope
What do I need to supply?
Just website URLs. No API keys, no credentials, no account on the target sites.
Do I need a proxy?
Usually not — these are public marketing pages fetched once or twice each. Enable Apify Proxy with the RESIDENTIAL group only if your targets block datacenter IPs.
Can I plug it into Claude, Cursor, or a LangChain agent?
Yes — it is a native MCP tool:
claude mcp add --transport http apify "https://mcp.apify.com?tools=techforce.global/website-contact-scraper-emails-phone-numbers-social-links"
I have company names, not websites. Can it find the sites?
No — it needs URLs. Run Google Maps Business Leads first to turn names and locations into websites, then feed those here.
The static/browser split
Why is it "static first"?
Because emails, tel: links and social URLs are in the served HTML on the large majority of sites. Rendering those pages in Chromium is paying browser prices for HTTP work. The static phase handles them; the browser is reserved for pages that genuinely need JavaScript.
How does it know a page needs a browser?
Two signals: the served HTML is an almost-empty single-page-app shell (a framework mount point like id="root" or id="__next" with under 500 characters of text), or the static fetch was refused outright (403/429/timeout). Either one escalates that URL to the browser phase.
Will auto miss anything that always would find?
It shouldn't — a site that renders contact details only via JavaScript is exactly what the escalation detects. If you find a case where always finds something auto misses, that's a bug worth reporting.
How do I tell whether a browser was used?
The engine field on each record: static, browser, or static+browser. The run log also prints Static phase done — N/M site(s) resolved without a browser.
Coverage & accuracy
Why did it only scan 2 pages when I allowed 4?
The early exit: once a domain has an email plus a phone or social profile, extra fetches would at best add a second address for the same company. Raise maxPagesPerDomain if you want exhaustive collection rather than a good contact point.
How does it decide which sub-pages to fetch?
By score, not link order. Footer links get a large bonus, contact/about/team/imprint paths score high, and blog/product/legal/auth/cart paths score to zero and are never fetched.
Are the emails verified?
No. They are addresses published on the page. Verify before sending anything at volume.
Which email is the primary one?
There isn't one — emails is deliberately unranked. Apply your own preference (contact@ → hello@ → info@ → sales@); the README has a snippet.
Why are some phone numbers not in E.164 format?
phonenumbers couldn't parse them confidently, so the raw display string is kept rather than dropping a real number. Related: parsing assumes the US as the default region, so purely local-format non-US numbers can be missed.
Does it read contact details from images?
No — there's no OCR.
Can it get named people and job titles?
No, this is company-level. Use LinkedIn Decision Makers.
Pricing
How is this billed?
Pay-per-result — one result per input URL, whether or not contacts were found — with no monthly subscription. The current rate is on the Actor's Pricing tab. Apify platform usage is billed separately.
How do I make a large run as cheap as possible?
renderJavaScript: "never" with maxPagesPerDomain: 2 and high concurrency for the first pass, then re-run only the no_data_found URLs with auto. The Python section has the two-pass snippet.
Am I charged for sites where nothing was found?
Yes — a record is produced for every input URL, including empty ones, and that record is the billable result. Validate your list before a bulk run.
How do I guarantee an agent never overspends?
Pass maxTotalChargeUsd as a query parameter on the run endpoint.
🔗 Related Actors
The enrichment chain
This Actor is the contact-extraction step. It needs URLs going in, and it produces company-level contact points coming out — which makes the Actors on either side of it the useful pairings.
| Actor | Position | What it adds |
|---|---|---|
| Google Maps Business Leads & Sales Intelligence | Before — finds the sites | Turns a place + query into businesses with websites, plus ratings and a website grade. Feed its website field straight into this Actor |
| This Actor | The contact step | All published emails, E.164 phones, 9 social platforms — one record per domain |
| LinkedIn Decision Makers | After — puts names to it | Founders, C-suite, VPs and directors at the company, which this Actor never returns |
| LinkedIn Employee Scraper | After — staff view | Every employee with a derived department and seniority band |
| GOV.UK Business Scraper | After — verification | Confirms a UK company is real and active, with PSC / beneficial ownership |
| Advanced Website Crawling Actor | Alternative | Full-site content crawling when you want the pages themselves, not just the contacts |
Vertical lead sources that pair well
Each returns businesses with websites — exactly the input this Actor wants:
| Actor | Feeds this Actor with |
|---|---|
| Construction Lead Scraper | Contractor and developer websites |
| Finance Lead Scraper | Financial firm websites |
| Healthcare Lead Scraper | Practice and clinic websites |
| Hospitality Lead Scraper | Venue, hotel and restaurant websites |
| DesignRush Agency Scraper · TopDevelopers Scraper | Agency and dev-shop websites |
Suggested pipeline patterns
📇 Name → website → contacts
Google Maps Business Leads → take the website field → this Actor → deliveryMode: "perSite" into your CRM. A complete lead record from a single place query.
🧹 CRM hygiene
Export the websites you already hold → this Actor with renderJavaScript: "never" → update rows where status == "success" and flag the rest for review.
🎯 Company contact + named buyer This Actor for the company inbox and switchboard → LinkedIn Decision Makers for the person to address → merge on the company. You get both the route in and the name.
🛡️ Verified UK outreach list Vertical lead scraper → this Actor for contacts → GOV.UK Business Scraper to confirm the entity is active before anyone spends outreach time.
💸 Two-pass bulk enrichment
Pass 1 renderJavaScript: "never" over the whole list → pass 2 auto over only the no_data_found URLs. Same coverage, a fraction of the browser cost.
Browse all Actors by Techforce Global at scraper.techforce.global.
🔐 Compliance & Data Privacy
This Actor reads publicly served pages on websites you supply. It does not log in, does not accept credentials, does not bypass authentication, and never submits a form or sends any message.
Some of what it returns is personal data. A role inbox like info@company.com generally is not, but firstname.lastname@company.com, a named person's direct line, and a personal LinkedIn profile URL are. Four consequences:
- You become a data controller when you store it. GDPR, UK GDPR and CCPA/CPRA apply to the personal-data subset. Have a lawful basis (legitimate interest is the usual route for B2B contact data, and it requires a balancing assessment), record it, set a retention period, and be ready to honour access, objection and erasure requests.
- Publication is not consent. A company publishing an address so customers can reach it has not agreed to receive marketing. That is a separate question under GDPR Art. 6/21, ePrivacy/PECR, CAN-SPAM and CASL — and several jurisdictions treat unsolicited B2B email differently from B2C.
- Nothing here is verified, so accuracy is your obligation. GDPR Art. 5(1)(d) requires personal data to be accurate and kept up to date. Extracted addresses go stale; don't treat a scrape from months ago as current.
- Prefer role inboxes for outreach.
contact@,sales@,info@carry far lower privacy risk than a named individual's address and are usually the correct route anyway. Theemailsarray lets you choose deliberately.
Respect the sites you point this at: the per-domain page budget is small by default and concurrency is capped, which keeps the footprint light — don't defeat that by running many parallel runs against the same domain. You remain responsible for complying with each target site's terms of service, robots.txt expectations, and all applicable data-protection and marketing law in the jurisdictions you operate in.
🆘 Support & Custom Pipeline Engineering
Need scheduled re-enrichment with change detection, email verification wired into the pipeline, region-aware phone parsing, or a full lead-generation workflow feeding your CRM?
- Email: bhavin.shah@techforceglobal.com
- Custom Enterprise Integrations: Book a 15-Minute Technical Consultation
- Maintained by: Techforce Global — Specialists in High-Performance Web Scrapers and Agentic Workflows.
Made with ❤️ by Techforce Global Specialists in High-Performance B2B Contact Data Extraction and AI Automation.
🏷️ Structured data for search & AI discovery
{"@context": "https://schema.org","@type": "SoftwareApplication","name": "Website Contact Scraper — Emails, Phone Numbers & Social Links","applicationCategory": "BusinessApplication","operatingSystem": "Cloud (Apify platform)","description": "Extracts contact details from any list of websites and returns one consolidated record per domain — every published email address, phone numbers normalised to E.164, and social profile links across 9 platforms — with contact, about and team pages discovered automatically by a scored footer-first ranking. Each site is fetched over plain HTTP first and rendered in a browser only when the page needs JavaScript, and a domain stops as soon as it has enough contact data. Results can be delivered into Airtable, Notion, Slack, Google Sheets or any authorized MCP connector.","url": "https://apify.com/techforce.global/website-contact-scraper-emails-phone-numbers-social-links","offers": {"@type": "Offer","priceCurrency": "USD","description": "Pay-per-result, charged per record returned (one per input URL). The current rate is listed on the Actor's Pricing tab."}}
{"@context": "https://schema.org","@type": "FAQPage","mainEntity": [{"@type": "Question","name": "What do I need to supply?","acceptedAnswer": {"@type": "Answer","text": "Just website URLs. No API keys, no credentials, no account on the target sites."}},{"@type": "Question","name": "Do I need a proxy?","acceptedAnswer": {"@type": "Answer","text": "Usually not - these are public marketing pages fetched once or twice each. Enable Apify Proxy with the RESIDENTIAL group only if your targets block datacenter IPs."}},{"@type": "Question","name": "Can I plug it into Claude, Cursor, or a LangChain agent?","acceptedAnswer": {"@type": "Answer","text": "Yes - it is a native MCP tool:"}},{"@type": "Question","name": "I have company names, not websites. Can it find the sites?","acceptedAnswer": {"@type": "Answer","text": "No - it needs URLs. Run Google Maps Business Leads first to turn names and locations into websites, then feed those here."}},{"@type": "Question","name": "Why is it \"static first\"?","acceptedAnswer": {"@type": "Answer","text": "Because emails, tel: links and social URLs are in the served HTML on the large majority of sites. Rendering those pages in Chromium is paying browser prices for HTTP work. The static phase handles them; the browser is reserved for pages that genuinely need JavaScript."}},{"@type": "Question","name": "How does it know a page needs a browser?","acceptedAnswer": {"@type": "Answer","text": "Two signals: the served HTML is an almost-empty single-page-app shell (a framework mount point like id=\"root\" or id=\"__next\" with under 500 characters of text), or the static fetch was refused outright (403/429/timeout). Either one escalates that URL to the browser phase."}},{"@type": "Question","name": "Will auto miss anything that always would find?","acceptedAnswer": {"@type": "Answer","text": "It shouldn't - a site that renders contact details only via JavaScript is exactly what the escalation detects. If you find a case where always finds something auto misses, that's a bug worth reporting."}},{"@type": "Question","name": "How do I tell whether a browser was used?","acceptedAnswer": {"@type": "Answer","text": "The engine field on each record: static, browser, or static+browser. The run log also prints Static phase done - N/M site(s) resolved without a browser."}},{"@type": "Question","name": "Why did it only scan 2 pages when I allowed 4?","acceptedAnswer": {"@type": "Answer","text": "The early exit: once a domain has an email plus a phone or social profile, extra fetches would at best add a second address for the same company. Raise maxPagesPerDomain if you want exhaustive collection rather than a good contact point."}},{"@type": "Question","name": "How does it decide which sub-pages to fetch?","acceptedAnswer": {"@type": "Answer","text": "By score, not link order. Footer links get a large bonus, contact/about/team/imprint paths score high, and blog/product/legal/auth/cart paths score to zero and are never fetched."}},{"@type": "Question","name": "Are the emails verified?","acceptedAnswer": {"@type": "Answer","text": "No. They are addresses published on the page. Verify before sending anything at volume."}},{"@type": "Question","name": "Which email is the primary one?","acceptedAnswer": {"@type": "Answer","text": "There isn't one - emails is deliberately unranked. Apply your own preference (contact@ -> hello@ -> info@ -> sales@); the README has a snippet."}},{"@type": "Question","name": "Why are some phone numbers not in E.164 format?","acceptedAnswer": {"@type": "Answer","text": "phonenumbers couldn't parse them confidently, so the raw display string is kept rather than dropping a real number. Related: parsing assumes the US as the default region, so purely local-format non-US numbers can be missed."}},{"@type": "Question","name": "Does it read contact details from images?","acceptedAnswer": {"@type": "Answer","text": "No - there's no OCR."}},{"@type": "Question","name": "Can it get named people and job titles?","acceptedAnswer": {"@type": "Answer","text": "No, this is company-level. Use LinkedIn Decision Makers."}},{"@type": "Question","name": "How is this billed?","acceptedAnswer": {"@type": "Answer","text": "Pay-per-result - one result per input URL, whether or not contacts were found - with no monthly subscription. The current rate is on the Actor's Pricing tab. Apify platform usage is billed separately."}},{"@type": "Question","name": "How do I make a large run as cheap as possible?","acceptedAnswer": {"@type": "Answer","text": "renderJavaScript: \"never\" with maxPagesPerDomain: 2 and high concurrency for the first pass, then re-run only the no_data_found URLs with auto. The Python section has the two-pass snippet."}},{"@type": "Question","name": "Am I charged for sites where nothing was found?","acceptedAnswer": {"@type": "Answer","text": "Yes - a record is produced for every input URL, including empty ones, and that record is the billable result. Validate your list before a bulk run."}},{"@type": "Question","name": "How do I guarantee an agent never overspends?","acceptedAnswer": {"@type": "Answer","text": "Pass maxTotalChargeUsd as a query parameter on the run endpoint."}}]}