# Website Contact Scraper — Bulk Email, Phone & Social Finder (`scrapersdelight/website-contact-scraper`) Actor

Turn a list of domains into one clean row per website: emails, phone numbers, 18 social profiles, postal address, contact-page URL and mail provider (MX/SPF/DMARC), read from the homepage plus its contact, about and imprint pages. You only pay for domains where a contact is actually found.

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

## Pricing

$1.00 / 1,000 per website 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 Contact Scraper — a domain list in, one clean contact row out

Give it a list of company websites. Get back **one row per domain** with the emails, phone numbers,
social profiles, postal address and contact-page URL that site publishes — plus who runs its mail
(MX / SPF / DMARC).

No API key. No login. No cookies. No CAPTCHA solving. Plain HTTPS GETs against the homepage and its
own contact / imprint / privacy / about pages.

**You only pay for domains where a contact is actually found.** Misses and blocked sites are still
pushed to your dataset — with a status telling you *why* — and cost you nothing.

***

### 🎯 The three things that make this different

Every number below is **measured**, on a real 60-domain run on Apify (build 0.1.2, Apify datacenter
proxy, 2026-09-03). Nothing here is estimated.

#### 1. You do not pay for the misses

The honest reality of this job is that **58% of company websites publish an email** and 42% route
everything through a web form. Competitors bill per page scanned or per dataset item regardless of
outcome — so on a 1,000-domain list you pay for 1,000 and get contacts on ~600.

Here, the 6 of 60 domains that returned nothing were pushed **free**. Billed rows: 54.

#### 2. "Blocked" is never reported as "no contacts found"

A zero row that was actually a wall is the single most expensive lie a contact scraper can tell you,
because you cross the domain off your list. Every row carries a `status`:

| `status` | Meaning | Billed? | Seen in the 60-domain run |
|---|---|---|---|
| `ok` | Pages read, at least one contact channel returned | ✅ | 54 |
| `no_contacts` | Pages read fine, the site genuinely publishes nothing | ❌ free | 1 |
| `js_only` | HTTP 200 but a client-rendered shell — no server-side contact surface | ❌ free | 3 |
| `challenge_blocked` | An anti-bot interstitial answered instead of the page | ❌ free | 2 |
| `dns_error` | The domain does not resolve (dead / typo / parked) | ❌ free | 0 |
| `timeout` | No response inside the timeout after 3 rotated attempts | ❌ free | 0 |
| `http_error` | A definitive 4xx from every host variant tried | ❌ free | 0 |
| `invalid_input` | The input string is not parseable as a domain | ❌ free | 0 |

`blockReason` spells it out in plain English, e.g.
`anti-bot interstitial (HTTP 200, 3036 bytes)` (hornbach.de) or
`HTTP 200 but only 27 chars of readable text across 1 page(s) — client-rendered shell` (tartinebakery.com).

#### 3. Deobfuscation + provenance on every address

Most contact scrapers run one email regex over the HTML. That misses every address a site
deliberately hid — and **on this run, 5 of the 35 domains with an email (14%) were only found
because of deobfuscation**:

| Domain | Recovered | Mechanism |
|---|---|---|
| `viget.com` | `info@viget.com` | JSON-LD published it as `info(at)viget.com` |
| `zingermans.com` | `service@zingermans.com` | written as `serv%69ce@zing%65%72%6d%61n%73.c%6fm` |
| `1800plumber.com` | `legal@`, `marketing@` | Cloudflare `data-cfemail` XOR encoding |
| `notion.so` | `team@makenotion.com`, `privacy@makenotion.com` | Cloudflare `data-cfemail` |
| `ikea.com` | `legal.affairs@`, `franchise@`, `infringement.report@inter.ikea.com` | Cloudflare `data-cfemail` |

And every address ships with **where it came from and how it was decoded**:

```json
"emailDetails": [
  { "email": "hello@viget.com",  "sources": ["mailto", "plaintext"],
    "foundOnUrls": ["https://www.viget.com/", "https://www.viget.com/contact"], "type": "role" },
  { "email": "info@viget.com",   "sources": ["at-dot-obfuscated"],
    "foundOnUrls": ["https://www.viget.com/"], "type": "role" },
  { "email": "legal@viget.com",  "sources": ["mailto", "plaintext"],
    "foundOnUrls": ["https://www.viget.com/privacy-policy"], "type": "role" }
]
```

Discovery mechanisms across the run: `plaintext` 26 domains · `mailto` 22 · `jsonld` 6 ·
`cloudflare-cfemail` 3 · `percent-encoded` 1 · `at-dot-obfuscated` 1.

***

### 📊 Measured results — 60 domains, 2026-09-03

**Run:** 60 domains → **60 rows**, 205 s wall clock, **166 pages fetched (2.77 per domain)**,
26 MB received, $0.019 total Apify platform usage. Mixed sample: 15 US home-service SMBs,
10 design/dev agencies, 10 restaurants and food brands, 15 SaaS/enterprise, 10 European retail.

#### Contact channels

| Signal | Fill | Notes |
|---|---|---|
| **At least one contact channel** | **54 / 60 (90%)** | this is what gets billed |
| Same-domain email | 35 / 60 (58%) | 78 addresses total |
| Phone number | 34 / 60 (57%) | `primaryPhoneE164` is always strict E.164; 25 were toll-free |
| ≥1 social profile | 50 / 60 (83%) | 3.6 platforms per domain on average |
| Contact page URL | 47 / 60 (78%) | 1 of them recovered via the `sitemap.xml` fallback |
| Postal address (any form) | 18 / 60 (30%) | |
| Structured street address | 16 / 60 (27%) | e.g. `Cincinnati, OH 45202` · `Falls Church, VA 22046` |
| MX resolved | 58 / 60 (97%) | |

#### Per-field fill, all 103 fields

| Fill | Fields |
|---|---|
| **100%** | `input_raw` · `domain` · `effectiveDomain` · `finalUrl` · `httpStatus` · `responseMs` · `fetchedAt` · `pagesFetchedCount` · `proxyGroupUsed` · `status` · `faviconUrl` · `emailCount` · `phoneCount` · `socialPlatformCount` · `contactFormCount` · `mxCount` · `placeholderEmailsFiltered` |
| **97–98%** | `hasSpf` · `spfRecord` · `hasDmarc` · `dmarcRecord` · `dmarcPolicy` · `pagesFetched` · `mxRecords` · `mailProvider` · `domainAcceptsMail` |
| **92–93%** | `companyDescription` · `language` · `companyName` |
| **77–83%** | `socialHandles` · `aboutPageUrl` · `contactPageUrl` · `schemaTypes` |
| **63–70%** | `facebook` · `instagram` · `privacyPageUrl` · `twitter` |
| **55–58%** | `emails` · `primaryEmail` · `primaryEmailType` · `emailSources` · `emailTypes` · `emailFoundOnUrl` · `emailDetails` · `phones` · `primaryPhone` · `primaryPhoneE164` · `phoneSources` · `phoneDetails` · `phoneCountry` · `logoUrl` · `roleEmails` · `youtube` |
| **45–52%** | `hasContactForm` · `contactFormUrl` · `contactFormFields` · `supportPageUrl` · `linkedinCompany` · `locationsPageUrl` · `cms` |
| **23–42%** | `tollFree` · `addressRaw` · `addressLocality` · `notes` · `streetAddress` · `postalCode` · `personalEmails` · `addressRegion` · `contactPoints` |
| **7–17%** | `legalName` · `addressCountry` · `offDomainEmails` · `tiktok` · `pinterest` · `vimeo` · `availableLanguage` · `blockReason` · `deobfuscatedEmails` · `github` · `imprintPageUrl` · `redirectedToDifferentDomain` · `alternateName` · `additionalAddresses` · `areaServed` |
| **2–5%** | `liveChatVendor` · `slogan` · `openingHours` · `reddit` · `trustpilot` · `faxNumber` · `googleMapsEmbedUrl` · `latitude` · `longitude` · `linkedinPeople` · `bookingUrl` |
| **0%** on this sample | `freemailEmails` · `yelp` · `whatsapp` · `telegram` · `threads` · `discord` · `crunchbase` · `glassdoor` |

The 0% fields are **not broken** — they are long-tail signals that a sample of US/EU corporates and
franchises simply does not carry. `yelp`, `whatsapp` and `freemailEmails` are SMB/local signals;
`crunchbase` and `glassdoor` are startup/recruiting signals. They are extracted the moment a site
publishes them, and are listed here at their honest observed rate rather than hidden.

***

### 📦 What you get on every row

#### Provenance and transport

`input_raw` · `domain` · `effectiveDomain` · `finalUrl` · `httpStatus` ·
`redirectedToDifferentDomain` · `responseMs` · `fetchedAt` · `pagesFetched[]` ·
`pagesFetchedCount` · `proxyGroupUsed` · `status` · `blockReason` · `notes[]`

#### Company identity

`companyName` · `legalName` · `alternateName` · `slogan` · `companyDescription` · `logoUrl` ·
`faviconUrl` · `language` · `schemaTypes[]` · `cms`

#### Emails

`emails[]` (ranked best-first) · `primaryEmail` · `primaryEmailType` · `emailSources[]` ·
`emailTypes[]` · `emailFoundOnUrl` · `emailDetails[]` · `roleEmails[]` · `personalEmails[]` ·
`freemailEmails[]` · `offDomainEmails[]` · `deobfuscatedEmails[]` · `placeholderEmailsFiltered` ·
`emailCount`

#### Phones

`phones[]` · `phonesE164[]` · `primaryPhone` · `primaryPhoneE164` · `phoneSources[]` · `phoneDetails[]` ·
`phoneCountry` · `tollFree` · `faxNumber` · `phoneCount`

#### Postal address and place

`streetAddress` · `addressLocality` · `addressRegion` · `postalCode` · `addressCountry` ·
`addressRaw` · `additionalAddresses[]` · `latitude` · `longitude` · `googleMapsEmbedUrl` ·
`openingHours[]` · `areaServed[]` · `availableLanguage[]` · `contactPoints[]`

#### Social profiles — 18 platforms, one column each

`linkedinCompany` · `facebook` · `instagram` · `twitter` · `youtube` · `tiktok` · `pinterest` ·
`yelp` · `whatsapp` · `telegram` · `github` · `reddit` · `threads` · `discord` · `vimeo` ·
`crunchbase` · `trustpilot` · `glassdoor` — plus `linkedinPeople[]`, `socialHandles`,
`socialPlatformCount`

#### Pages and conversion surfaces

`contactPageUrl` · `aboutPageUrl` · `imprintPageUrl` · `privacyPageUrl` · `supportPageUrl` ·
`locationsPageUrl` · `hasContactForm` · `contactFormCount` · `contactFormUrl` ·
`contactFormFields[]` · `bookingUrl` · `liveChatVendor`

#### Domain mail signals

`mxRecords[]` · `mxCount` · `mailProvider` · `hasSpf` · `spfRecord` · `hasDmarc` · `dmarcRecord` ·
`dmarcPolicy` · `domainAcceptsMail`

***

### 🚀 How to use it

Paste your domains and run. Anything goes in the list — `acme.com`, `www.acme.com`,
`https://acme.com/some/page` — duplicates are collapsed by host.

```json
{
  "domains": ["clearleft.com", "viget.com", "blueskypest.com"],
  "maxPagesPerDomain": 3
}
```

A real row from the run above:

```json
{
  "domain": "viget.com",
  "status": "ok",
  "companyName": "Viget",
  "primaryEmail": "hello@viget.com",
  "primaryEmailType": "role",
  "emails": ["hello@viget.com", "info@viget.com", "careers@viget.com",
             "legal@viget.com", "business@viget.com", "publicity@viget.com"],
  "roleEmails": ["hello@viget.com", "info@viget.com", "careers@viget.com", "legal@viget.com"],
  "deobfuscatedEmails": ["info@viget.com"],
  "primaryPhone": "+17038910670",
  "phoneSources": ["tel-href", "jsonld-contactPoint", "plaintext"],
  "streetAddress": "4th Floor, 105 W Broad St",
  "addressLocality": "Falls Church", "addressRegion": "VA", "postalCode": "22046",
  "contactPageUrl": "https://www.viget.com/contact/",
  "mailProvider": "Google Workspace", "hasSpf": true, "dmarcPolicy": "quarantine",
  "pagesFetched": ["https://www.viget.com/", "https://www.viget.com/contact",
                   "https://www.viget.com/privacy-policy"]
}
```

#### How the pages are chosen

1. `GET https://<domain>/` — and if the bare host does not serve, `www.` and `http://` are tried too.
2. Every same-domain link is classified into **contact / imprint / privacy / about / support /
   locations**. All six URLs are returned; the top `maxPagesPerDomain - 1` are actually fetched, in
   that priority order (contact first, then imprint — legally required to carry a mailbox in DE/AT/CH
   — then privacy, which is where `privacy@`, `legal@` and `dpo@` usually live).
3. If the homepage exposes no contact-ish link at all, `sitemap.xml` is fetched as a fallback. On
   this run that is how `metalab.com`'s contact page was found.

`maxPagesPerDomain` counts the homepage. The price is **per domain, not per page** — raising it
costs you nothing extra.

***

### 💰 Pricing

**$0.001 per website where a contact is found.** That is **$1 per 1,000 domains** — and only for the
ones that produced something.

| | Event | Price |
|---|---|---|
| ✅ | `contacts-scraped` — a domain that returned at least one contact channel | **$0.001** |
| 🆓 | a domain that returned nothing, or was blocked | **free** — pushed to the dataset with a `status` |

Charging is budget-aware: rows are delivered and billed in the same atomic call, so a run that hits
your charge cap stops delivering instead of handing you unpaid rows.

On the measured run, 54 of 60 domains billed → **$0.054 for 60 domains**, or about **$0.90 per 1,000
domains** at the observed 90% hit rate.

***

### ⚙️ Input reference

| Field | Default | What it does |
|---|---|---|
| `domains` | — | Your list of websites. Empty runs a 3-domain demo so you can see the shape first. |
| `maxPagesPerDomain` | `3` | Total pages per site **including** the homepage. 1–10. |
| `useSitemapFallback` | `true` | Fetch `sitemap.xml` when homepage nav exposes no contact link. |
| `includeDnsSignals` | `true` | Resolve MX / SPF / DMARC and name the mail provider. |
| `respectRobotsTxt` | `false` | Honour each site's `User-agent: *` rules; skipped pages appear in `notes`. |
| `requestConcurrency` | `12` | Domains in flight. Higher causes footer misses through contention. |
| `requestTimeoutSecs` | `15` | Per request; each page gets up to 3 attempts. |
| `maxDomains` | `0` | Stop after N domains. 0 = no limit. |
| `proxyConfiguration` | Apify auto (datacenter) | See the proxy note below before switching to residential. |

***

### 🕵️ Reliability — what actually goes wrong, and what we do about it

**Transport failure is the #1 risk on this lane, not the target sites.** Three things are built in:

- **Every retry rotates the proxy session.** In pre-build testing, Apify RESIDENTIAL succeeded on
  only 7 of 12 domains on the *first* attempt — the other 5 returned a proxy-side `590 UPSTREAM504`
  with **0 bytes**, and all 5 returned HTTP 200 on a retry with a fresh session. Zero bytes is broken
  transport, never a verdict. Without this the actor would report a ~40% "no contacts" rate that is
  pure plumbing.
- **The HTTP version is part of the retry ladder.** Measured 2026-09-03: `segment.com` and
  `figma.com/contact/` answer HTTP/1.1 with a **400 (524 B / 122 B)** and the identical request over
  **HTTP/2 with a 200 (610 KB / 1.55 MB)**. That 400 described the client, not the site. Attempts
  alternate protocols, which is why both domains return full contact rows.
- **Redirects to a different domain are followed properly.** `notion.so → notion.com`,
  `segment.com → twilio.com`, `bmw.com → bmwusa.com` and `precisiondoor.com → precisiondoor.net` all
  resolved correctly on the run; link discovery re-anchors to where the site actually landed, and
  `redirectedToDifferentDomain` + `effectiveDomain` tell you it happened.

**A per-domain failure is a row, never a run failure.** Even a run where every domain is blocked
exits SUCCEEDED with an explanatory status message, so your schedule never shows a red run for
something a target did.

#### ⚠️ Proxy note — do not flip this to residential by reflex

The default is Apify's automatic (datacenter) pool, which returned HTTP 200 on 20/20 pre-build test
domains and on **58 of the 60** domains in the live run (the 2 misses were anti-bot walls that
answer any client the same way, not datacenter blocks). Residential is available as an opt-in for targets that block datacenter
IPs, but **it is slower, less reliable on the first attempt, and moves ~433 KB of billable
residential traffic per domain** (measured: 26 MB across 60 domains). At typical residential rates
that costs several times what the row itself costs you. Use it deliberately, on a short list of hard
targets — not as a default.

***

### ⚖️ Honest limits — what this actor does NOT do

- **No SMTP / mailbox verification, and we will not pretend otherwise.** Outbound TCP port 25 is
  blocked on the Apify platform (verified from a live run: MX hosts on :25 gave `ETIMEDOUT` in
  \~260 ms while `smtp.gmail.com:587` returned a live 220 banner from the same run). Even where port
  25 *is* reachable, **Google Workspace and Microsoft 365 accept every RCPT** — a garbage mailbox
  gets the same `250 2.1.5 OK` as a real one, and those two providers were **48 of the 58**
  MX-resolved domains on this run. What ships instead is **domain-level** signal: MX, SPF, DMARC and
  the provider name. That is deliverability context, not proof a specific mailbox exists.
- **42% of company websites publish no email.** Large brands route everything through a web form. On
  those rows you still get `hasContactForm`, `contactFormUrl`, `contactFormFields[]`, the phone, the
  socials and the address — and you are not charged if there is nothing at all.
- **Client-rendered sites return `js_only`, not contacts.** This actor does not run a browser. 3 of
  60 domains (`tartinebakery.com`, `work-co.com`, `adidas.de`) served an HTTP 200 shell with under
  35 characters of readable text. They are honestly flagged, not silently zeroed.
- **Some sites are behind an anti-bot wall.** 2 of 60 (`hornbach.de` — a Fastly interstitial served
  as HTTP 200 in 3,036 bytes; `decathlon.fr` — HTTP 403 in 5,919 bytes). No CAPTCHA solving is
  performed. Bring your own residential proxies via `proxyConfiguration` if a specific target matters.
- **No person-level discovery.** This returns the company's contact *channels*. It does not hunt for
  a named decision-maker, a job title, or a personal address pattern, and it does not guess
  `first.last@`. `linkedinPeople[]` only carries profile links the site itself published.
- **Postal address fill is 30%, not 90%.** Most websites do not publish a street address at all.
- **Phone numbers are validated, not scraped naively.** `tel:` links and JSON-LD `telephone` are
  trusted; a plaintext number is only kept if it survives strict E.164/NANP validation. A naive regex
  on real pages emits `000000000`, `1788400694899` and `(615) 675-2312801` — those are dropped here,
  which is why the phone fill rate is 57% and not a fake 90%.
- **`phones[]` is not all E.164 — use `phonesE164[]` if you need strict format.** A number the site
  itself published in a `tel:` href or JSON-LD is kept even when it will not normalise: vanity
  numbers, national short codes and malformed `tel:` hrefs are real leads and dropping them would
  lose contacts. On the 60-domain reference run, 44 of 165 values in `phones[]` (27%, all from one
  domain's 100-entry list) are non-E.164 in exactly this way. `phonesE164[]`, `primaryPhoneE164` and
  `phoneCountry` are the strictly normalised fields; `phoneE164Count` tells you how many survived.

#### 🤖 robots.txt

This actor visits **arbitrary user-supplied domains**, so there is no single robots.txt to quote —
the rules differ for every target on your list and cannot be pre-cleared. A `respectRobotsTxt`
toggle ships in the input schema (default off, matching how most contact-enrichment tooling behaves);
with it on, each site's `User-agent: *` `Disallow` rules are parsed and honoured, and any page skipped
because of them is named in that row's `notes`. Everything collected is public company contact
information published on the site's own pages — no login, no cookies, no CAPTCHA solving, no
paywalled or member-only content. You are responsible for how you use the output: check applicable
marketing and data-protection law (GDPR, CAN-SPAM, CASL) before contacting anyone.

***

### ❓ FAQ

**How many pages does it fetch per domain?**
2.77 on average, measured (166 pages for 60 domains) with the default `maxPagesPerDomain: 3`.

**Do I get charged for a domain that returns nothing?**
No. Only `contacts-scraped` bills, and it only fires when at least one contact channel came back.
The row still lands in your dataset with a `status` and a `blockReason`.

**Why is `primaryEmail` sometimes a `privacy@` or `legal@` address?**
Because that is often the only real mailbox a large company publishes. Addresses are ranked
same-domain first, then role over personal over freemail, with `noreply@` pushed to the bottom.
Every alternative is in `emails[]`, and `roleEmails[]` / `personalEmails[]` split them for you.

**What is `offDomainEmails`?**
Third-party addresses found on the page — a parent company's privacy inbox, a franchise operator, an
agency. On the run: `privacy@authoritybrandsllc.com` on a franchise site, `privacy@37signals.com` on
basecamp.com. They are separated so they never become your `primaryEmail`, but they are still
returned because they are often the correct escalation contact.

**Can it find a specific person's email?**
No — that is deliberately out of scope. This returns company-level contact channels only.

**Does it work outside the US?**
Yes. Imprint (`/impressum`) pages are a first-class hop and phone numbers are normalised to E.164
where they parse
from any country code. On the run, `zalando.de` returned `datenschutz@`, `legalnotice@` and
`authorities-dsa@zalando.de` plus its imprint URL, and `ikea.com` returned three Cloudflare-encoded
`inter.ikea.com` addresses.

**What happens with an empty input?**
It runs a small built-in 3-domain demo instead of failing, so a first click always shows you the
output shape.

***

### 🔬 Validation record

| Check | Result |
|---|---|
| Real Apify run, 60 domains, build 0.1.2, 1024 MB, datacenter proxy | **SUCCEEDED** — 60 rows, 205 s, $0.019 |
| Rows with a contact channel | 54 / 60 (90%) — billed; 6 free |
| Distinct fields emitted | **103** |
| Prefill-only input × 3 (Apify's daily health test) | SUCCEEDED, 3 rows each, **8–11 s** |
| Empty `{}` input × 2 | SUCCEEDED, 3 rows each, 9 s and 31 s, ≤ $0.0027 |
| Parser validated offline against captured bytes | 6 real homepages + unit cases for every obfuscation form |

# Actor input Schema

## `domains` (type: `array`):

One company website per line — `acme.com`, `www.acme.com` or `https://acme.com/anything` all work, and duplicates are collapsed by host. Each domain returns exactly ONE row. Leave this empty and the actor runs a small built-in demo list so you can see the output shape before spending anything.

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

Total pages fetched per website, INCLUDING the homepage. The default of 3 is the homepage plus the two best contact-ish hops (contact → imprint → privacy → about → support → locations), which is where essentially all of the extra email yield sits. Raising it finds a little more on big sites and costs you nothing extra — the price is per domain, not per page.

## `useSitemapFallback` (type: `boolean`):

When the homepage navigation exposes no contact/about/imprint link at all (common on JS-heavy sites with a rendered footer), fetch `/sitemap.xml` and pick the contact page out of it. In recon this recovered a `/contact` page on 2 of the 3 domains where nav discovery found nothing. One extra request, only on domains that need it.

## `includeDnsSignals` (type: `boolean`):

Resolve the domain's MX, SPF and DMARC records and name the mail provider (Google Workspace, Microsoft 365, …). This is DOMAIN-level deliverability signal — it tells you the domain accepts mail and who runs it. It is NOT mailbox verification; see the README's honest-limits section.

## `respectRobotsTxt` (type: `boolean`):

Fetch each site's robots.txt and skip any page its `User-agent: *` rules disallow. Off by default because most sites' robots.txt is written for search crawlers and blocking a `/contact` page is rare; turn it on if your own policy requires it. Skipped pages are named in the row's `notes`.

## `requestConcurrency` (type: `integer`):

How many websites to work on at once. 12 is the tested sweet spot: pushing much higher causes footer/contact-page misses through connection contention, which read as false 'no email found'.

## `requestTimeoutSecs` (type: `integer`):

Per-request timeout. Each page gets up to 3 attempts with a freshly rotated proxy session, so a slow site still gets a fair chance before it is recorded as `timeout`.

## `maxDomains` (type: `integer`):

Stop after this many domains from your list. 0 = no limit. Useful for a cheap first pass over the top of a big list.

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

Defaults to Apify Proxy's automatic (datacenter) pool — 20/20 test domains returned 200 through it, and it keeps the run cheap. Switch to RESIDENTIAL only for targets that block datacenter IPs: it is slower, and in testing only 7 of 12 domains succeeded on the FIRST attempt (the actor retries with a rotated session, so they all still returned data). You can also supply your own proxies here.

## Actor input object example

```json
{
  "domains": [
    "clearleft.com",
    "viget.com",
    "blueskypest.com"
  ],
  "maxPagesPerDomain": 3,
  "useSitemapFallback": true,
  "includeDnsSignals": true,
  "respectRobotsTxt": false,
  "requestConcurrency": 12,
  "requestTimeoutSecs": 15,
  "maxDomains": 0,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `contacts` (type: `string`):

The dataset of scraped websites (one item per domain).

# 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 = {
    "domains": [
        "clearleft.com",
        "viget.com",
        "blueskypest.com"
    ],
    "maxPagesPerDomain": 3,
    "useSitemapFallback": true
};

// Run the Actor and wait for it to finish
const run = await client.actor("scrapersdelight/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 = {
    "domains": [
        "clearleft.com",
        "viget.com",
        "blueskypest.com",
    ],
    "maxPagesPerDomain": 3,
    "useSitemapFallback": True,
}

# Run the Actor and wait for it to finish
run = client.actor("scrapersdelight/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 '{
  "domains": [
    "clearleft.com",
    "viget.com",
    "blueskypest.com"
  ],
  "maxPagesPerDomain": 3,
  "useSitemapFallback": true
}' |
apify call scrapersdelight/website-contact-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,scrapersdelight/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/RDeL15iLO6xZq980y/builds/WFt4CLWxQDC7t2PLJ/openapi.json
