# Website Contact Details Scraper – Emails, Phones & Socials (`zkill3r/website-contact-details-scraper`) Actor

Give it a list of websites or domains: it crawls each site's contact, about, team and impressum pages and returns one clean lead record per website with emails, phone numbers (E.164) and LinkedIn, Facebook, Instagram, X, YouTube, TikTok, WhatsApp and other social profiles.

- **URL**: https://apify.com/zkill3r/website-contact-details-scraper.md
- **Developed by:** [Zee Lex](https://apify.com/zkill3r) (community)
- **Categories:** Lead generation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $7.50 / 1,000 website scrapeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Website Contact Details Scraper – Emails, Phones & Socials

Give it a list of **websites or domains**. For each one it crawls the most useful pages — homepage, contact, about, team, impressum/imprint and footer links — and returns **one merged, deduplicated lead record per website** with:

- **Emails**, including addresses hidden by Cloudflare email protection or written as `name [at] domain [dot] com`. Junk such as `logo@2x.png`, `you@example.com` and tracking keys is dropped.
- **Phone numbers**, normalized to **E.164** (`+14155550132`), plus the raw text as found. Dates, prices, postcodes and VAT/registration numbers are not mistaken for phones.
- **Social profiles**: LinkedIn, Facebook, Instagram, X/Twitter, YouTube, TikTok, Pinterest, GitHub, WhatsApp, Telegram and Threads. Share buttons, posts and platform home pages are left out.
- The homepage **title**, **meta description** and **language**, and the URLs of the site's **contact pages**.

It works over plain HTTP, so it is fast and cheap. An optional browser fallback handles sites built entirely with JavaScript.

### Use cases

- **Lead generation:** turn a list of company websites into emails, phone numbers and LinkedIn pages ready for outreach.
- **Enrich Google Maps or LinkedIn lists:** add contact details to businesses you scraped elsewhere, using their website column.
- **CRM enrichment:** fill missing emails, phones and social links for the accounts in your CRM, and spot dead websites (`status: "unreachable"`).
- **Market research:** see which competitors are active on which social networks.

### Input example

```json
{
  "startUrls": [
    { "url": "https://www.example-dental.com" },
    { "url": "acme-widgets.co.uk" }
  ],
  "websites": "beispiel-gmbh.de\nhttps://www.example.com.my/about",
  "maxPagesPerSite": 10,
  "maxDepth": 2,
  "sameDomainOnly": true,
  "prioritizeContactPages": true,
  "includeEmails": true,
  "includePhones": true,
  "includeSocials": true,
  "phoneCountryHint": "",
  "outputPerPage": false,
  "useBrowserFallback": false
}
```

The inputs:

- **Websites** can be full URLs or bare domains. Inputs on the same registrable domain (`www.example.com`, `shop.example.com`, `example.com/contact`) count as one website.
- **`maxPagesPerSite`** includes the homepage. Contact-type pages are fetched first. Ordinary links may use only half of what is left of the limit, so contact pages found deeper still fit.
- **`phoneCountryHint`** lets the scraper read numbers written without a country code, such as "(415) 555-0132". When it is empty:
  - the website's country domain is used (`.my` → MY, `.co.uk` → GB, `.de` → DE);
  - on `.com`/`.org` sites, only `+country` numbers are read from text;
  - `tel:` links are always read.

An empty input (`{}`) scrapes apify.com and python.org with 5 pages each and finishes in well under a minute.

### Output example

One record per website (`type: "website"`):

```json
{
  "type": "website",
  "inputUrl": "acme-widgets.co.uk",
  "domain": "acme-widgets.co.uk",
  "siteUrl": "https://www.acme-widgets.co.uk",
  "siteTitle": "Acme Widgets — Industrial Widgets since 1999",
  "description": "Acme Widgets makes industrial widgets.",
  "language": "en-GB",
  "emails": ["sales@acme-widgets.co.uk", "support@acme-widgets.co.uk", "acme.widgets@gmail.com"],
  "emailsSameDomain": ["sales@acme-widgets.co.uk", "support@acme-widgets.co.uk"],
  "phones": ["+442079460018", "+441614960000"],
  "phonesRaw": ["020 7946 0018", "+44 161 496 0000"],
  "linkedin": ["https://www.linkedin.com/company/acme-widgets"],
  "facebook": ["https://www.facebook.com/acmewidgets"],
  "instagram": ["https://www.instagram.com/acmewidgets"],
  "x": ["https://x.com/acmewidgets"],
  "youtube": ["https://www.youtube.com/@acmewidgets"],
  "tiktok": [],
  "pinterest": [],
  "github": [],
  "whatsapp": ["https://wa.me/447700900123"],
  "telegram": [],
  "otherSocials": [],
  "contactPageUrls": ["https://www.acme-widgets.co.uk/contact-us"],
  "pagesCrawled": 8,
  "status": "ok",
  "error": null,
  "scrapedAt": "2026-10-29T08:00:00.000Z"
}
```

All values above are examples.

**`status`** is one of:

- **`ok`**: contact details were found.
- **`no_contacts`**: the site was crawled, but nothing was found.
- **`unreachable`**: DNS failure, connection refused or timeout. `error` says which.
- **`blocked`**: 403/429 or a bot-challenge page, still blocked after one retry on a new session.

The other fields:

- **`emailsSameDomain`** lists the emails on the website's own domain or its subdomains. Those come first in `emails`.
- **`otherSocials`** holds Threads profiles.

With `outputPerPage: true`, every crawled page also gets a free record: `{ "type": "page", "domain", "url", "emails", "phones", "phonesRaw", "socials" }`.

The run summary is in the key-value store under `OUTPUT`:

```json
{ "sites": 250, "sitesWithEmail": 191, "sitesWithPhone": 168, "sitesWithSocials": 203, "unreachable": 9, "blocked": 4, "pagesCrawled": 1840 }
```

### Pricing

Pay per event:

- **`website`** is charged once per website record with status `ok` or `no_contacts`. Unreachable and blocked websites are **free**.
- **`page-browser`** is charged per page rendered in the browser. This only happens with `useBrowserFallback`, and only for pages that had almost no text over HTTP.

Per-page records are free. The run stops cleanly when your maximum cost per run is reached.

> **Setup note for the Actor owner:** in Monetization, create the events `website` and `page-browser`. Do not enable the default per-dataset-item event.

### How it works

1. **Each website** is normalized to its origin and crawled from its homepage, following redirects (http → https, apex → www). If https fails at the network level, plain http is tried once.
2. **Links** on the same registrable domain are ranked. Contact, about, team, impressum, legal, privacy, support, staff, people and careers pages, in many languages, come first. Crawling stops at `maxPagesPerSite` and `maxDepth`. These are skipped:
   - PDFs, images, archives and other files
   - login and cart pages
   - pages over 5 MB
3. **Extraction runs on every page:**
   - `mailto:` and `tel:` links
   - the visible text, with obfuscated addresses decoded
   - the raw HTML, with HTML entities decoded
   - Cloudflare-protected emails
   - JSON-LD structured data (`email`, `telephone`, `sameAs`)
4. **Merging.** Everything a website's pages yielded is merged and deduplicated into one record.
5. **Browser fallback** (optional). After the HTTP crawl, pages with almost no text are rendered in a headless browser and read again. Links that only appear after rendering are followed within the same limits.
6. **Safety:**
   - One failing website never stops the others.
   - About 45 seconds before the run's timeout, the scraper stops and saves a record for every website with what it has collected.
   - The run fails only if every website was unreachable.

### FAQ

**Why are some phone numbers only in `phonesRaw`?**
Some numbers can't be turned into a full international number: a `tel:` link without a country code on a `.com` site, or a short service number. Set `phoneCountryHint` to read national-format numbers on generic domains.

**Why is `emailsSameDomain` empty when `emails` isn't?**
The business uses an address on another domain (Gmail, a parent company, an agency). Those are still in `emails`.

**Does it find emails that are only shown after clicking or in images?**
Emails in images or behind a click-to-reveal button can't be read. Cloudflare-protected emails, HTML entities and "\[at]"/"\[dot]" spellings are decoded.

**A website shows `blocked`. What can I do?**
The site's bot protection refused the request twice. Run those websites again with RESIDENTIAL proxy, and with `useBrowserFallback` for JavaScript-heavy sites. Blocked websites are not charged.

**Can I give it a list from a spreadsheet?**
Yes. Paste the column into **Websites (plain list)**, one website per line, or pass `startUrls` through the API.

**Is it legal?**
The scraper reads public pages only. Emails and phone numbers of people are personal data. Make sure your use, especially cold outreach, complies with the GDPR, CAN-SPAM, PDPA and the other laws that apply to you.

### Development

```bash
npm install
npm test                 # unit tests + end-to-end runs against local fixture websites (E2E_BROWSER=0 skips the browser run)
npm run fixture-sites    # serve the fixture websites on :4611–4616 to try the Actor by hand
npm start                # run locally (input in storage/key_value_stores/default/INPUT.json)
```

# Actor input Schema

## `startUrls` (type: `array`):

Websites to scrape: full URLs or bare domains ("example.com", "https://www.example.com/page"). Each website is crawled from its homepage. Inputs on the same registrable domain (www.example.com, shop.example.com, example.com/contact) are treated as one website. Leave empty (with no list below) to try the Actor on apify.com and python.org.

## `websites` (type: `string`):

Optional. Paste a list of websites or domains, one per line (commas also work). Combined with the list above.

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

Maximum pages fetched from each website, homepage included (1–100). Contact, about, team and impressum pages are fetched first.

## `maxDepth` (type: `integer`):

How many clicks away from the homepage to go. 0 = homepage only, 1 = pages linked from the homepage, 2 = one more level.

## `sameDomainOnly` (type: `boolean`):

Only follow links on the website's own registrable domain (subdomains such as shop.example.com are allowed). Turn off to also follow links to other domains (they count towards the page limit).

## `prioritizeContactPages` (type: `boolean`):

Fetch links that look like contact, about, team, impressum/imprint, legal, privacy, support, staff, people or careers pages (in many languages) before other links, so the page limit is spent where contact details are.

## `includeEmails` (type: `boolean`):

Collect email addresses (mailto links, page text, Cloudflare-protected and obfuscated addresses).

## `includePhones` (type: `boolean`):

Collect phone numbers from tel: links, page text and structured data, normalized to E.164 (+14155550132).

## `includeSocials` (type: `boolean`):

Collect LinkedIn, Facebook, Instagram, X/Twitter, YouTube, TikTok, Pinterest, GitHub, Threads, WhatsApp and Telegram profile links (share buttons and posts are ignored).

## `phoneCountryHint` (type: `string`):

Optional two-letter country code (US, GB, MY, DE…) used to read phone numbers written without a country code, e.g. "(415) 555-0132". When empty, each website's country domain is used (.my → MY, .co.uk → GB); on .com/.org sites only numbers with a +country code are read from text (tel: links are always read).

## `outputPerPage` (type: `boolean`):

Besides the merged website record, save a "page" record for every crawled page with what was found on it. Page records are free.

## `useBrowserFallback` (type: `boolean`):

When a page comes back with almost no text (a site built with JavaScript), render it in a headless browser and read it again. Slower; each rendered page is charged as a "page-browser" event.

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

Apify datacenter proxy by default. A 403/429 or bot-challenge page is retried once on a new session; if it is still blocked, the website gets status "blocked" (free). Use RESIDENTIAL proxy for sites behind strict bot protection.

## Actor input object example

```json
{
  "startUrls": [
    {
      "url": "https://apify.com"
    },
    {
      "url": "https://www.python.org"
    }
  ],
  "websites": "",
  "maxPagesPerSite": 5,
  "maxDepth": 2,
  "sameDomainOnly": true,
  "prioritizeContactPages": true,
  "includeEmails": true,
  "includePhones": true,
  "includeSocials": true,
  "phoneCountryHint": "",
  "outputPerPage": false,
  "useBrowserFallback": false,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `websites` (type: `string`):

One merged record per website (type "website"), plus per-page records (type "page") when enabled.

## `allFields` (type: `string`):

Every field of every record.

## `summary` (type: `string`):

sites, sitesWithEmail, sitesWithPhone, sitesWithSocials, unreachable, blocked, pagesCrawled.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "startUrls": [
        {
            "url": "https://apify.com"
        },
        {
            "url": "https://www.python.org"
        }
    ],
    "websites": "",
    "maxPagesPerSite": 5,
    "maxDepth": 2,
    "sameDomainOnly": true,
    "prioritizeContactPages": true,
    "includeEmails": true,
    "includePhones": true,
    "includeSocials": true,
    "phoneCountryHint": "",
    "outputPerPage": false,
    "useBrowserFallback": false,
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("zkill3r/website-contact-details-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "startUrls": [
        { "url": "https://apify.com" },
        { "url": "https://www.python.org" },
    ],
    "websites": "",
    "maxPagesPerSite": 5,
    "maxDepth": 2,
    "sameDomainOnly": True,
    "prioritizeContactPages": True,
    "includeEmails": True,
    "includePhones": True,
    "includeSocials": True,
    "phoneCountryHint": "",
    "outputPerPage": False,
    "useBrowserFallback": False,
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("zkill3r/website-contact-details-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "startUrls": [
    {
      "url": "https://apify.com"
    },
    {
      "url": "https://www.python.org"
    }
  ],
  "websites": "",
  "maxPagesPerSite": 5,
  "maxDepth": 2,
  "sameDomainOnly": true,
  "prioritizeContactPages": true,
  "includeEmails": true,
  "includePhones": true,
  "includeSocials": true,
  "phoneCountryHint": "",
  "outputPerPage": false,
  "useBrowserFallback": false,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call zkill3r/website-contact-details-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,zkill3r/website-contact-details-scraper"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/irHtx9UfDeQgOTBBE/builds/ADAIIRzB0ORkyftFx/openapi.json
