# Website Email & Phone Scraper - Contact Details Extractor (`neverempty/website-contacts`) Actor

Finds emails, phone numbers and social profiles on any website. Decodes obfuscated addresses, refuses to invent ones that are not there, and tells you when a site blocked it instead of returning a blank row. Charges only when contacts are actually found.

- **URL**: https://apify.com/neverempty/website-contacts.md
- **Developed by:** [NeverEmpty](https://apify.com/neverempty) (community)
- **Categories:** Lead generation, Developer tools, MCP servers
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$15.00 / 1,000 site with contacts founds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

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

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

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

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Website Email & Phone Scraper - Contact Details Extractor

Give it a list of websites. Get back the emails, phone numbers and social profiles that are actually published on them — one row per site, in the order you gave them.

### The problem with contact scrapers

Most of them will hand you an address that does not exist.

Here is a real example, produced by this Actor's own first version and then fixed. A page said:

> Contact the **team at dealer.com** for details

A naive "decode obfuscated emails" step turns `at` into `@` and produces **`team@dealer.com`** — an address nobody wrote, on a domain that has nothing to do with the site. Send an outreach campaign to a list built that way and you are mailing strangers.

**This Actor only decodes what is unambiguously obfuscation** — `info [at] acme [dot] com`, `&#64;` entities, Cloudflare's `data-cfemail` protection — and leaves ordinary English prose alone.

It also drops, before you ever see them:

- `no-reply@`, `donotreply@`, `postmaster@`, `abuse@`, `bounce@`
- `@example.com`, `@test.com`, `@yourdomain.com` and other placeholders
- image filenames that look like addresses (`logo@2x.png`)
- Sentry keys and other long hex strings that happen to contain `@`

### What you get

One row per website:

| Field | Example |
|---|---|
| `domain` | `www.debian.org` |
| `ok` | `true` — false only when nothing could be read at all |
| `emails` | `["press@debian.org", "events@debian.org", ...]` |
| `phones` | `["+4998315050"]` — E.164, international form |
| `phonesLocal` | `["8887334281"]` — numbers the site declared in a `tel:` link or schema.org without a country code. Kept **exactly as written** rather than guessed at, or dropped |
| `socials` | `{"linkedin": [...], "twitter": [...], "github": [...]}` |
| `emailCount` / `phoneCount` / `phoneLocalCount` | `15` / `2` / `0` |
| `pagesScanned` | `4` |
| `sourcePages` | The exact URLs the contacts came from — so you can verify every one |
| `reason` | Why a row is empty, when it is |

**`sourcePages` matters.** Every contact can be traced back to the page it came from. If you doubt a result, you can open the page and look.

### It tells you which kind of empty it is

A blank row can mean three completely different things, and most scrapers give you the same blank for all of them:

- **The site blocked us** → `ok: false`, `reason: "The site blocked us: blocked by the site (HTTP 403)"`, and **you are not charged**
- **The pages loaded but publish no contact details** → `ok: true`, `reason` says exactly that, and **you are not charged**
- **We found contacts** → the arrays are filled, and only then does it cost anything

### It does not invent phone numbers

Phone validation is not something to hand-write. This Actor uses **Google's libphonenumber** — the same library your phone uses — to decide what is a phone number and what is not.

A digit sequence on a page is usually not a phone number. Measured against live pages, a naive regex turns all of these into "phone numbers":

| What it really is | Seen on | Naive result |
|---|---|---|
| A float in a code sample — `5.666666666666667` | python.org | `+1 666 666 666 6667` |
| The Fibonacci sequence — `55 89 144 233` | python.org | `+1 558 914 4233` |
| A copyright range — `2001-2026` | python.org | `+1 200 120 26` |
| A VAT number — `DE811128135` | bosch.com, hetzner.com | `+49 811 128 135` |
| A ZIP+4 postal code — `10504-1722` | ibm.com | `+1 105 041 722` |
| A template placeholder — `tel:111-111-1110` | docker.com | `+1 111 111 1110` |
| A fiction-reserved number — `+1 415 555 0132` | anywhere | libphonenumber calls this **valid**; we still drop it |

This Actor returns **none** of them.

### It never guesses which country a number belongs to

There is no "default country code" option, and that is deliberate.

suse.com lists its offices with local numbers and no country codes. Stamp one country onto all of them and you get numbers that reach nobody:

| On the page | What a default country code produces | Reality |
|---|---|---|
| `Brussels, Belgium Tel: 31-23-8080010` | `+49 31 238 080 010` | The number is Dutch |
| `Budapest, Hungary Tel: 361-489-4600` | `+49 361 489 4600` — **and libphonenumber calls it a valid German landline** | The number is Hungarian |
| `Auckland Tel: 0800 569 047` | `+49 800 569 047` | New Zealand freephone |

That last one is the dangerous kind: a fabricated number that passes validation, so the buyer cannot tell it is wrong.

So numbers are split into two columns:

- **`phones`** — the number carried its own country code. Returned in E.164, ready to dial.
- **`phonesLocal`** — the site published it without one. Returned **exactly as written**, never with a country guessed onto it.

Nothing is thrown away, and nothing is made up. Measured on 2026-08-27 across suse.com, bbva.com, huawei.com, ionos.de, chiyoda.lg.jp and others: **27 numbers in `phones`, every one of which libphonenumber confirms is valid, and 0 invented.**

Two more things it gets right, each found on a real page:

- **`&#43;` is a plus sign.** ibm.com writes `&#43;65-6418-1000`. Decode it and you have Singapore. Don't, and the `+65` is lost.
- **`+49 (0)9831 505-0`** — the German `(0)` must be dropped when dialling internationally, and `(+33) 01 45 66 22 22` keeps its French trunk `0`. Both are handled.

### It respects robots.txt

On by default. It reads each site's `robots.txt`, applies only the rules addressed to `*` or to this bot (not rules meant for someone else's crawler), honours `*` and `$` patterns, and lets a more specific `Allow` override a broader `Disallow` — the standard interpretation.

### Where it looks

The page you give it, plus the contact pages it finds from there: `contact`, `about`, `company`, `support`, and — importantly — **`impressum`, `kontakt`, `imprint`, `mentions-légales`**. In German-speaking countries an Impressum is legally required to carry a real address, phone number and email, which makes it the single richest page on a European site. Scrapers that only look for the English word "contact" walk straight past it.

If no such link exists, it tries the common paths directly instead of giving up.

### Input

```json
{
  "websites": ["acme.com", "https://example-corp.de", "https://another.co.uk"],
  "maxPagesPerSite": 5,
  "respectRobots": true
}
```

| Field | Default | Meaning |
|---|---|---|
| `websites` | 3 sample sites | Domains or URLs. One row out per row in |
| `maxPagesPerSite` | `5` | Pages read per site (1–20) |
| `respectRobots` | `true` | Read and honour robots.txt |
| `timeoutSecs` | `20` | Per-page timeout |

### Pricing

**You are charged per site where at least one contact was actually found** (the current rate is shown on this page).

Sites that block you, and sites that simply publish no contact details, **cost nothing**. Compare that with per-row pricing that charges for blanks: the number that matters is the price per *usable* lead, not the price per row.

### Typical uses

- Building outreach lists from a set of company domains
- Enriching a CRM with published contact details
- Finding the right inbox for partnership, press or support enquiries
- Compliance and due-diligence checks that need a company's published contact information

### FAQ

**Does it need a proxy?**
No. It reads ordinary company websites over plain HTTP.

**Will it get blocked?**
Some sites block automated requests. When that happens you are told so explicitly — and not charged.

**Can it find personal emails of employees?**
It reports what a site publishes. It does not guess address patterns, and it does not invent `firstname.lastname@` combinations.

**How fast is it?**
Roughly 5 pages per site. Three sites with five pages each took well under a minute when measured.

### Other tools by NeverEmpty

Every NeverEmpty Actor follows the same rule: it never returns an empty result to mean two different things, and it only charges for rows that actually carry an answer.

- **[domain-lookup](https://apify.com/neverempty/domain-lookup)** - WHOIS, DNS, MX, SPF, DMARC and SSL expiry for a list of domains
- **[ats-board-finder](https://apify.com/neverempty/ats-board-finder)** - a company domain to the ATS and board name it uses
- **[seo-site-audit](https://apify.com/neverempty/seo-site-audit)** - technical SEO audit: meta, canonical, robots, headings

### Support

Found a wrong address, or a site where a contact exists but was missed? Open an issue on the **Issues** tab with the domain. `sourcePages` in the output tells me where to look.

# Actor input Schema

## `websites` (type: `array`):

Domains or URLs to scan, for example "acme.com" or "https://acme.com". Each one produces exactly one row, so you can match inputs to outputs one to one.

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

How many pages to read per site. The first page is the one you give; the rest are contact, about and imprint pages found from it.

## `respectRobots` (type: `boolean`):

Read each site's robots.txt and skip anything it disallows. On by default.

## `timeoutSecs` (type: `integer`):

How long to wait for a single page before giving up on it.

## Actor input object example

```json
{
  "websites": [
    "https://www.mozilla.org",
    "https://www.python.org",
    "https://www.debian.org"
  ],
  "maxPagesPerSite": 5,
  "respectRobots": true,
  "timeoutSecs": 20
}
```

# Actor output Schema

## `results` (type: `string`):

All contact rows produced by this run.

# 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 = {};

// Run the Actor and wait for it to finish
const run = await client.actor("neverempty/website-contacts").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 = {}

# Run the Actor and wait for it to finish
run = client.actor("neverempty/website-contacts").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 '{}' |
apify call neverempty/website-contacts --silent --output-dataset

```

## MCP server setup

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

```

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/5t4PLnsSgqzRdLW1U/builds/eeuC87iAS1ocz2VEw/openapi.json
