# Website Contact Enricher (`ihord/website-contact-enricher`) Actor

Turn a list of company websites into one validated record per company: general business emails, phone numbers in international format, contact and about pages and official social profiles, each with its source page and a clear status.

- **URL**: https://apify.com/ihord/website-contact-enricher.md
- **Developed by:** [Ahmet Yiğit Sertel](https://apify.com/ihord) (community)
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $3.00 / 1,000 website enricheds

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?

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 Enricher

Give it a list of company websites. For each one it reads the start page and the contact, about and
legal pages it finds from there (up to 5 pages by default), and returns **one record per company**
with:

- **General business emails** such as `info@`, `sales@`, `support@`, `press@` (English and Turkish role
  names), plus Turkish KEP (registered e-mail) addresses in their own field, `kepEmails`.
- **Phone numbers in international format** (E.164, for example `+902125551234`), with the country the
  number was read in.
- **The contact page and about page** URLs, and official social profiles (LinkedIn, Facebook,
  Instagram, ...).
- **A company name** when the site's own data supports one, otherwise `null`.
- **The source of every value**: the page it was read on, how it was found and the text as written.
- **A clear status**: `complete`, `partial` or `failed`, with a reason code for failures. "Not on the
  site" (`not_found`) is kept apart from "could not check" (`unknown`).

Typical uses: enriching a CRM or lead list with a general contact, cleaning a company directory,
finding the contact page of a list of domains. Turkish and English sites are first-class: `İletişim`,
`Hakkımızda`, `Kurumsal` are recognised, names keep their Turkish characters and legacy encodings such
as windows-1254 are decoded.

All pages of a website are merged into one record with a stable ID, so re-runs and duplicate start
URLs never create duplicate companies.

### What it deliberately does not do

- It does not collect personal employee emails. Addresses that look like a person's name are counted in
  `rejectedCandidates` and not returned; a business that publishes only a personal-looking address gets
  no email.
- It does not guess addresses (`info@domain` is never invented) and does not run JavaScript.
- It reads only the websites you give it. It obeys `robots.txt`, refuses private network addresses, does
  not follow redirects to other websites, and does not bypass CAPTCHAs, logins or blocks.

### Pricing

**Pay per event. One event: `website-enriched` at USD 0.002 per website (USD 2.00 per 1,000 websites
with contact data).** Platform usage (compute) is included in that price; there is no separate usage
charge.

- **What is charged:** one event for each website for which the Actor delivers at least one business
  email, KEP address or phone number. Company names, social profiles and page links are not charged
  separately.
- **What carries no event charge:** websites that failed, were blocked or refused, or were read without
  yielding a contact value. They are still delivered as records with their status, so you can see why.
- **Not covered by "no event charge":** Apify adds its own small start event, `apify-actor-start`, to
  every pay-per-event run: USD 0.00005 per start for up to 1 GB of run memory, plus the same again for
  each additional GB. It applies to every run, including a run in which all sites fail or are empty.
  Run the Actor at its default memory to pay it once.
- **No per-record charge:** there is no charge per dataset item. Only `website-enriched` and the Apify
  start event exist.
- **Spending limit:** the maximum total charge you set is never exceeded. When it is reached, websites
  with contact data that would exceed it are **not delivered**; their domains are saved in the
  key-value store record `WITHHELD_FOR_BUDGET` so you can raise the limit and run just those. Records
  that cost nothing are still delivered. If the limit cannot pay for even one website, the run ends
  before crawling.
- **Restarts:** a platform restart repeats the work but never writes or charges a website twice.

### Input

| Field                   | Type    | Default  | Notes                                                            |
| ----------------------- | ------- | -------- | ---------------------------------------------------------------- |
| `startUrls`             | list    | required | One URL per company. `https://www.example.com` or `example.com`. |
| `maxPagesPerDomain`     | 1-10    | 5        | Start page plus contact/about/legal pages found from it.         |
| `maxConcurrency`        | 1-50    | 10       | Parallel requests across all websites.                           |
| `includeSocialLinks`    | boolean | true     | Official social profiles from header, footer, contact areas.     |
| `includeSourceEvidence` | boolean | true     | Every page that shows each value, with the original text.        |
| `allowSubdomains`       | boolean | false    | Also read subdomains of the given sites.                         |
| `proxyConfiguration`    | object  | none     | Optional. One session per run, never rotated to evade blocks.    |

Several URLs of the same site count as one company. A run is capped at 100,000 pages, so at the
default page budget at most 20,000 websites fit in one run; split larger lists. In practice the run
time limit is reached first on long lists: set it to match your list (a 30-website test run took under two
minutes at the default concurrency). Input errors stop the run before any request.

Example input:

```json
{
  "startUrls": [{ "url": "https://zrfmlaw.com/" }, { "url": "https://lrb.co.uk" }],
  "maxPagesPerDomain": 5
}
```

### Output

One dataset item per company. Open the **Company contacts** view or download the dataset as JSON, CSV
or Excel.

| Field                                    | Description                                                                                                                                                                                                                                     |
| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`, `domain`, `startUrls`, `sourceUrl` | Stable company ID, the site key (host without `www.`) and the URLs you gave.                                                                                                                                                                    |
| `companyName`                            | `value`, `method`, `confidence`, `sourceUrl`, `supportedBy`; `null` when no candidate is convincing.                                                                                                                                            |
| `emails[]`                               | `value`, `confidence`, `method`, `sourceUrl`, `domainMatchesSite`, optional `evidence[]`.                                                                                                                                                       |
| `kepEmails[]`                            | Turkish registered e-mail addresses, kept only when the name relates to the company.                                                                                                                                                            |
| `phones[]`                               | `value` (E.164), `international`, `country`, `confidence`, `method`, `sourceUrl`, optional `evidence[]`.                                                                                                                                        |
| `contactPage`, `aboutPage`               | `url`, `verified` (page itself was read) and where the link was seen.                                                                                                                                                                           |
| `socialProfiles`                         | `linkedin`, `facebook`, `instagram`, `others[]`.                                                                                                                                                                                                |
| `extractionStatus`, `confidence`         | `complete` / `partial` / `failed`, and the best confidence among the values.                                                                                                                                                                    |
| `fieldStatus`                            | Per field: `found`, `not_found` or `unknown`.                                                                                                                                                                                                   |
| `failure`                                | `null` or `{code, message, url}`; codes: `out_of_scope`, `blocked_by_policy`, `robots_disallowed`, `access_restricted`, `http_error`, `timeout`, `network_error`, `invalid_url`, `unsupported_scheme`, `unsupported_url`, `detail_unavailable`. |
| `pages`, `pageFailures`, `qualityFlags`  | Pages planned/fetched, which pages failed, and notes such as `js_navigation_suspected`.                                                                                                                                                         |
| `rejectedCandidates`                     | Counts of invalid or non-general emails and invalid or ambiguous phones that were left out.                                                                                                                                                     |
| `adapterVersion`, `scrapedAt`            | Version of the extraction logic and extraction time.                                                                                                                                                                                            |

Run-level data is in the key-value store: `RUN_REPORT` (websites by status, skipped and duplicate
inputs, billing), `RUN_SUMMARY` (requests, retries, warnings) and, when a spending limit stops
delivery, `WITHHELD_FOR_BUDGET`. The run exits with an error only when not a single page could be read.

Example output (shortened; a real record from a test run):

```json
{
  "domain": "zrfmlaw.com",
  "companyName": {
    "value": "Zukowski, Rogers, Flood & McArdle",
    "method": "structured_data",
    "confidence": "high"
  },
  "emails": [
    {
      "value": "info@zrfmlaw.com",
      "method": "mailto",
      "confidence": "high",
      "sourceUrl": "https://zrfmlaw.com/",
      "domainMatchesSite": true
    }
  ],
  "phones": [
    {
      "value": "+18154592050",
      "international": "+1 815 459 2050",
      "country": "US",
      "method": "tel_link",
      "confidence": "high",
      "sourceUrl": "https://zrfmlaw.com/"
    }
  ],
  "contactPage": { "url": "https://zrfmlaw.com/contact-us/", "verified": true },
  "extractionStatus": "complete",
  "failure": null
}
```

A website that cannot be read still produces a record, so nothing disappears silently. It has no event
charge:

```json
{
  "domain": "sarniaplumbing.com",
  "emails": [],
  "phones": [],
  "extractionStatus": "failed",
  "failure": {
    "code": "network_error",
    "message": "Start URL refused: Could not resolve sarniaplumbing.com: getaddrinfo ENOTFOUND sarniaplumbing.com (https://sarniaplumbing.com/)",
    "url": "https://sarniaplumbing.com/"
  }
}
```

#### Status fields

| Field                  | Values                             | Meaning                                                                                                                                                                                |
| ---------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `extractionStatus`     | `complete` / `partial` / `failed`  | `complete`: every planned page was read. `partial`: some pages were not (see `pageFailures`). `failed`: no page was read (see `failure`).                                              |
| `fieldStatus.*`        | `found` / `not_found` / `unknown`  | `not_found` is claimed only after a complete look at the site. After failures, with the page limit reached, or when navigation looks script-built, absence is `unknown`.               |
| `confidence`           | `high` / `medium` / `low` / `none` | Best confidence among emails and phones. `high`: `mailto`, `tel:` or structured data. `medium`: text in a contact area. One level lower when the email domain differs from the site's. |
| `contactPage.verified` | boolean                            | `true`: the page itself was read. `false`: only a link to it was seen.                                                                                                                 |
| `qualityFlags`         | strings                            | For example `page_limit_reached`, `js_navigation_suspected`, `email_domain_differs_from_site`, `multiple_linkedin_profiles`.                                                           |

### How to use

1. Paste your company websites into **Company websites** (or upload a list).
2. Leave the defaults for a first run; set a **maximum total charge** if you want a hard spending limit.
3. Start the run. Open the **Company contacts** view or download the dataset as JSON, CSV or Excel.
4. Filter on `extractionStatus` and `failure.code` to see which sites need another look.

### How it works

1. Each start URL defines a company by its host (`www.` is ignored). Only that host is fetched;
   subdomains only if you turn on **Read subdomains**. If a site redirects its own page to one other
   host of the same registrable domain (`gitlab.com` to `about.gitlab.com`), that host is followed and
   flagged `canonical_redirect_followed`.
2. The start page is read. From its links the Actor picks, conservatively, up to two contact pages,
   up to two legal-notice or privacy pages (where companies state their registered e-mail,
   data-protection contact and KEP address), one support or press page when the start page shows no
   address, and one about page, all within the page budget. Blog posts, product pages, links with
   query strings and other sites are never followed.
3. If a page shows no menu links at all but looks script-driven, up to four conventional paths
   (`/contact`, `/iletisim`, `/about`, `/hakkimizda`) are tried. A page is accepted as a contact page
   only if its own title or heading says so.
4. Values are taken from `mailto:`/`tel:` links, the organization's structured data (JSON-LD,
   microdata) and clearly identified contact areas (`address`, footer, contact sections, contact
   pages), and role mailboxes on the company's own domain in page text. Other text is ignored.
5. The pages of a site are merged, de-duplicated and written once, at the end of the run.

### Run time limit

The Actor stops shortly before the run's time limit (about 8% of it, between 30 and 120 seconds) and
writes what it has read: websites not reached appear as `failed` with `detail_unavailable`, and
`RUN_REPORT.stoppedForTimeLimit` is `true`. Nothing is lost silently. (A run killed for memory still
loses its output, because records are written when the crawl ends.)

### Known limitations

- Plain HTTP only: contact details that appear only after JavaScript runs are not seen. The record says
  so (`js_navigation_suspected`, `unknown`) instead of reporting `not_found`.
- Only role/general mailboxes are returned. The role-name vocabulary is finite (English, Turkish and some
  German, French, Spanish, Italian); an unusual name or a mailbox named after a place is missed. A KEP
  address is kept only when its name relates to the company.
- Numbers are returned only when the country is certain: from the site's country domain, structured
  data, page language, an international prefix, or a North American number in its usual written form.
  Numbers in running body text outside header, footer, contact areas and contact pages are not read.
- A site that redirects to another domain is not followed (`out_of_scope`).
- Sites that block datacenter traffic return 403 and are reported as `access_restricted`; the Actor
  does not work around it.
- A site whose `robots.txt` cannot be read is not fetched (reported as `timeout`, `network_error` or
  `http_error`), and can take up to about 50 seconds to fail.
- Records are written when the run ends, and a platform migration restarts the run from scratch.
- Tested on lists of up to 100 websites; behaviour on lists of thousands has not been measured. Start
  with a few thousand websites per run.
- No accuracy figure is claimed: results are as complete as the websites publish them.

### Responsible use

The Actor reads public pages that anyone can open and keeps only company-level contact channels.
Business contact data can still be personal data (for example a sole trader's address), and rules such
as GDPR, KVKK and e-privacy/anti-spam laws apply to what you do with it. You are responsible for a
lawful basis for your use, for each website's terms, and for honouring opt-outs.

# Actor input Schema

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

One URL per company, e.g. https://www.example.com. Only these sites are read; the Actor never follows links to other sites. Several URLs of the same site count as one company.

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

The start page plus the contact, about, legal-notice/privacy and support/press pages found from it (1-10). Only these kinds of page on the same website are followed, by their English and Turkish names.

## `maxConcurrency` (type: `integer`):

Parallel requests across all websites (1-50). Every website is a third party, so keep this modest (default 10).

## `includeSocialLinks` (type: `boolean`):

Add the official LinkedIn, Facebook, Instagram and other social profiles found in the site's header, footer, contact areas and structured data.

## `includeSourceEvidence` (type: `boolean`):

Add, for each email and phone number, every page that shows it with the method and the text as written. Switch off for smaller records; each item keeps its best source URL either way.

## `allowSubdomains` (type: `boolean`):

Off by default: only the exact host you gave (www is ignored) is read. Switch on to also read pages on its subdomains, such as shop.example.com.

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

Optional. All requests go through this proxy. One proxy session is used for the whole run.

## Actor input object example

```json
{
  "startUrls": [
    {
      "url": "https://example.com"
    }
  ],
  "maxPagesPerDomain": 5,
  "maxConcurrency": 10,
  "includeSocialLinks": true,
  "includeSourceEvidence": true,
  "allowSubdomains": false
}
```

# Actor output Schema

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

No description

## `report` (type: `string`):

No description

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

No description

# 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://example.com"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("ihord/website-contact-enricher").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://example.com" }] }

# Run the Actor and wait for it to finish
run = client.actor("ihord/website-contact-enricher").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://example.com"
    }
  ]
}' |
apify call ihord/website-contact-enricher --silent --output-dataset

```

## MCP server setup

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

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/k0FzGcRejV2YbbiHW/builds/c8cBdNSWWZZilIz9e/openapi.json
