# Website Contact Scraper (`spookyweb/website-contact-scraper`) Actor

Turn company websites into contact records. Finds emails split into personal and role, phone numbers, social profiles across 17 platforms, postal address and named people. Renders JavaScript sites at no extra cost. One charge per domain, and domains with nothing found are free.

- **URL**: https://apify.com/spookyweb/website-contact-scraper.md
- **Developed by:** [丂卩ㄖㄖҜㄚ](https://apify.com/spookyweb) (community)
- **Categories:** Lead generation, Integrations, AI
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $72.00 / 1,000 website scanneds

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/platform/actors/running/actors-in-store#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 Contact Scraper

Company websites in, one contact record per domain out: emails, phone numbers, social profiles, postal address, opening hours and named people.

### 🔍 What does Website Contact Scraper do?

It reads a company's own website and pulls out the contact details published on it, then returns one record per registrable domain so www and subdomains collapse into a single row.

One charge per domain that produced contact details, whatever the size of the site. **Domains where nothing is found are not charged.**

#### Exactly what it extracts

| | From | Notes |
|---|---|---|
| **Email addresses** | `mailto:` links and page text | Split three ways, see below |
| **Phone numbers** | `tel:` links and page text | Each says which it came from |
| **Social profiles** | Links anywhere on the page | 17 platforms, listed below |
| **Postal address** | schema.org markup only | Street, city, region, postcode, country |
| **Opening hours** | schema.org markup only | As published |
| **Named people** | schema.org `Person` markup only | Name, job title, email where given |

The 17 platforms: LinkedIn, X, Facebook, Instagram, YouTube, GitHub, TikTok,
Pinterest, Threads, Bluesky, Mastodon, Crunchbase, Reddit, Discord, Telegram,
WhatsApp and Snapchat.

### 📊 What data can I extract from a website?

One record per domain:

| Field | What it is |
|---|---|
| `domain` | Registrable domain, so www and subdomains collapse to one record |
| `hasContactData` | Whether anything was found. False is never charged |
| `emails` | Each with `type`, `isFreeProvider`, `onSiteDomain`, `foundOn` |
| `emailCount` | With `personalEmailCount`, `roleEmailCount`, `unclassifiedEmailCount` |
| `phones` | Each with `source`, `tel-link` or `text`, and `foundOn` |
| `socials` | Profile URLs keyed by platform |
| `people` | Name, job title, email where published |
| `address`, `hours` | From schema.org, with the page each came from |
| `pagesCrawled`, `pagesVisited` | Exactly what was read to produce the record |
| `renderedWithBrowser` | Whether the browser fallback was needed |
| `error` | Set only when no page could be read at all |

A domain that returns nothing still produces a record with `hasContactData:
false`, unless you switch that off. That is a real finding, not a failure: it
tells you the company publishes no contact details.

### 💡 Why scrape website contact details?

**B2B outreach.** Build a contact list from what businesses publish themselves, with role and personal addresses kept apart so your first line lands right.

**Lead enrichment.** Take a list of domains you already have and attach phones, socials and addresses to each one.

**Local business research.** Collect opening hours and postal addresses from schema.org markup across a sector or region.

**List hygiene.** See which domains publish nothing at all before you spend anything on them.

### 🚀 How do I use Website Contact Scraper?

1. Click **Try for free**.
2. Put a website into `url`, or up to 500 into `urls`.
3. Set `maxPagesPerDomain`, and turn on `deepScan` if the sites have sparse menus.
4. Click **Start** and wait for the run to finish.
5. Download the results as JSON, CSV or Excel, or pull them from the API.

### ⬇️ Input

```json
{
  "url": "monzo.com",
  "maxPagesPerDomain": 5
}
```

| Field | Type | Default | What it does |
|---|---|---|---|
| `url` | string | `monzo.com` | A single website, protocol optional |
| `urls` | array | | Up to 500 websites, charged per domain |
| `maxPagesPerDomain` | integer | `5` | Pages to crawl per domain, 1 to 20 |
| `deepScan` | boolean | `false` | Also probe standard contact paths |
| `jsFallback` | boolean | `true` | Render in a browser only when the HTML yielded nothing |
| `includeFreeProviders` | boolean | `true` | Keep gmail.com and similar addresses |
| `onlyWithContacts` | boolean | `false` | Drop empty domains from the output |
| `timeoutSecs` | integer | `20` | Per page timeout |

### ⬆️ Output

#### Table view

Results arrive as a Sites table you can sort and filter in the Console, with the domain, whether contact data was found, the addresses and phones, how many social platforms were seen, how many people were named and the page count lined up per domain.

#### JSON

A typical row:

```json
{
  "domain": "monzo.com",
  "hasContactData": true,
  "emails": [
    { "email": "help@monzo.com", "local": "help", "domain": "monzo.com", "type": "role" }
  ],
  "phones": [
    { "phone": "+44 20 7946 0000", "source": "tel-link", "foundOn": "https://monzo.com/help" }
  ],
  "socialPlatformCount": 5,
  "peopleCount": 0,
  "pagesCrawled": 5,
  "emailCount": 1
}
```

Download it from the run as JSON, CSV or Excel, or read it straight from the API.

### Three address types, not two

Most tools split addresses into personal and role. That forces a guess on every
single word local part, and the guess is wrong often enough to matter.

| Type | Meaning |
|---|---|
| `role` | A known department word: `info`, `sales`, `support`, `careers` |
| `personal` | Name shaped, two parts around a separator: `jane.smith`, `j.smith` |
| `unclassified` | A single word that could be either: `greg`, `solar`, `heat` |

`solar@`, `heat@` and `greg@` are the same shape. Two are departments and one is
a person, and nothing in the address says which. Calling a department mailbox a
named human is the expensive direction of that mistake, because the first line
of your outreach lands wrong. So those come back `unclassified` rather than
assigned.

Every address also carries `isFreeProvider`, and `onSiteDomain`, which is false
when the address sits on a different domain to the site. That is how you tell a
company's own address from its agency's.

### How it reads a site

The homepage first, then the pages most likely to carry contact details, ranked:
contact, imprint, about, team, then support and legal. A shallow `/contact`
beats a deep `/blog/2019/contact-us-update`. Same registrable domain only, so a
link to a partner's site is never followed.

`deepScan` also tries the standard contact paths directly, including the imprint
and legal notice pages European sites are required to publish. Useful when a
site's menu is sparse or built in JavaScript.

### JavaScript rendering, included

If a site's HTML carries no contact details at all, the page is loaded in a
browser and read after its scripts have run. On a client rendered site that is
the difference between a record and an empty row.

It runs only where the plain fetch found nothing, so ordinary sites never pay
the time for it, and **there is no separate charge when it does run.**

### What it deliberately does not do

Each of these is a refusal to guess, because in an outreach list a wrong answer
costs more than a missing one.

- **Names are never inferred from headings.** Only schema.org `Person` markup is
  read. Guessing from `<h2>` returns product names and page furniture presented
  as staff.
- **Bare digit runs are never read as phone numbers.** A price, an order number
  and a company registration number are indistinguishable from a phone number to
  a loose pattern.
- **Addresses are never inferred from text.** Without schema.org markup, the
  address is reported absent.

It also does not: verify mailboxes by SMTP, use a paid enrichment database, read
login gated pages, or follow links off the domain. It finds what a company chose
to publish on its own website, and nothing beyond that.

### 💰 How much does it cost?

| Event | Price |
|---|---|
| Website scanned | $0.12 |

Charged once per domain that produced contact details. Not per page, not per
address found, and not extra when a browser was needed. A run over 100 sites
with a 60% hit rate costs $7.20, not $12.

No API keys, no accounts, no proxies.

### 🔌 Integrations

Send results straight to Google Sheets, Slack, Airtable, Zapier, Make or your own webhook using [Apify integrations](https://docs.apify.com/platform/integrations). You can also trigger a run whenever something happens in another tool.

### 🔗 Using Website Contact Scraper with the Apify API

```bash
curl -X POST "https://api.apify.com/v2/acts/spookyweb~website-contact-scraper/run-sync-get-dataset-items?token=YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "monzo.com", "maxPagesPerDomain": 5}'
```

Or with the Apify client:

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

const client = new ApifyClient({ token: 'YOUR_TOKEN' });
const run = await client.actor('spookyweb/website-contact-scraper').call({
  url: 'monzo.com',
  maxPagesPerDomain: 5,
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
```

Full detail is in the [Apify API reference](https://docs.apify.com/api/v2), and every run is also callable from the [Python](https://docs.apify.com/api/client/python) and [JavaScript](https://docs.apify.com/api/client/js) clients.

### ❓ FAQ

#### Which pages does it read?

The homepage first, then the pages most likely to carry contact details, ranked: contact, imprint, about, team, then support and legal. A shallow `/contact` beats a deep `/blog/2019/contact-us-update`. It stays on the same registrable domain, so a link to a partner's site is never followed. `maxPagesPerDomain` caps how many are read, and `deepScan` also probes the standard contact paths directly.

#### What does the JavaScript fallback do, and when does it trigger?

It loads the page in a browser and reads it after the scripts have run. It triggers only where the plain HTML fetch found no contact details at all, so ordinary sites never wait for it, and there is no separate charge when it does run. `renderedWithBrowser` on the record tells you whether it was needed.

#### Are free provider addresses like gmail included?

Yes by default, and every address carries `isFreeProvider` so you can filter them out yourself. Set `includeFreeProviders` to false to drop them during the run instead. Small businesses often publish a Gmail address as their only contact, so throwing them away by default would lose real data.

#### What are the three address types?

`role` is a known department word such as info, sales, support or careers. `personal` is name shaped, two parts around a separator such as `jane.smith`. `unclassified` is a single word that could be either, such as `greg`, `solar` or `heat`. Those stay unclassified rather than being guessed at, because calling a department mailbox a named human is the expensive direction of the mistake.

#### Are domains with nothing found still charged?

No. The charge only happens when a domain produced contact details. A run over 100 sites with a 60% hit rate costs $7.20 rather than $12. The empty domains still appear in the dataset with `hasContactData: false`, unless you set `onlyWithContacts` to drop them.

#### Can it find named people?

Only where the site publishes schema.org `Person` markup, in which case you get the name, job title and email where given. Names are never inferred from headings, because guessing from an `<h2>` returns product names and page furniture presented as staff.

### ⚖️ Is it legal to scrape website contact details?

This reads contact details a business has published on its own website so customers can reach it, which is business contact information. It reads only public pages and never anything behind a login.

Where a detail identifies a named person, UK GDPR applies and you are the data controller for what you do next. You need a lawful basis, typically legitimate interest for B2B contact, and you must honour opt outs. Apify's [ethical scraping guide](https://blog.apify.com/is-web-scraping-legal/) covers the wider picture.

### 👍 Your feedback

Found a bug, or want a field that is not here yet? Open an issue on the Actor's Issues tab. Requests that make the data more useful get built, and problems get fixed quickly.

### 🔎 You might also like

| Actor | What it does |
|---|---|
| [Company Email Finder](https://apify.com/spookyweb/company-email-finder) | Published company addresses, the naming pattern behind them and an MX check |
| [Wayback Machine Scraper](https://apify.com/spookyweb/wayback-machine-scraper) | Every archived capture of a URL, what changed between them, and the original bytes |
| [UK Food Hygiene Ratings](https://apify.com/spookyweb/uk-food-hygiene-ratings) | Food hygiene ratings for every UK food business, straight from the FSA |

# Actor input Schema

## `url` (type: `string`):

A single company website to scan. The protocol is optional, so acme.com and https://acme.com are the same request.

## `urls` (type: `array`):

Scan up to 500 company websites in one run. Takes priority over the single website field when both are given. Each domain is charged once, whatever the size of the site.

## `maxPagesPerDomain` (type: `integer`):

How many pages to read on each site. The homepage is always read, then the pages most likely to carry contact details, ranked ahead of the rest: contact, imprint, about, team, then support and legal.

## `deepScan` (type: `boolean`):

Also try the usual contact URLs directly even when the site does not link to them, including the imprint and legal notice pages that European sites are required to publish. Slower, and worth it on sites with a sparse or JavaScript driven menu.

## `jsFallback` (type: `boolean`):

If a site's HTML carries no contact details at all, load it in a browser and read the page after its scripts have run. This is what recovers contacts from client rendered sites, and it is included rather than charged as an extra. Only used where the plain fetch found nothing, so it costs nothing on ordinary sites.

## `includeFreeProviders` (type: `boolean`):

Keep addresses on free consumer providers such as gmail.com and outlook.com. Useful for sole traders and small businesses that publish a personal address, and noise if you only want corporate domains.

## `onlyWithContacts` (type: `boolean`):

Drop domains where nothing was found instead of returning an empty record for them. Those domains are never charged either way, so this only affects whether they appear in the output.

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

How long to wait for a single page before moving on. Raise it for slow sites, lower it to get through a large list faster.

## Actor input object example

```json
{
  "url": "monzo.com",
  "maxPagesPerDomain": 5,
  "deepScan": false,
  "jsFallback": true,
  "includeFreeProviders": true,
  "onlyWithContacts": false,
  "timeoutSecs": 20
}
```

# Actor output Schema

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

One row per item: websites with the contact details found.

# 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 = {
    "url": "monzo.com"
};

// Run the Actor and wait for it to finish
const run = await client.actor("spookyweb/website-contact-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 = { "url": "monzo.com" }

# Run the Actor and wait for it to finish
run = client.actor("spookyweb/website-contact-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 '{
  "url": "monzo.com"
}' |
apify call spookyweb/website-contact-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,spookyweb/website-contact-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/VfKrgOcFVNTClVHs8/builds/3M2PB1lFd4rdPwfV9/openapi.json
