# Website Contact Extractor — Bulk Company Domains (`tidytools/website-contact-extractor-bulk`) Actor

Extract public company website emails, phone numbers and social links from bulk domain lists or a source dataset. One site result by default; $0.002 per site with a contact, free when none is found.

- **URL**: https://apify.com/tidytools/website-contact-extractor-bulk.md
- **Developed by:** [Yukai Lin](https://apify.com/tidytools) (community)
- **Categories:** Lead generation, Social media, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.60 / 1,000 processed websites

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 Extractor for Bulk Company Domains

### What does Website Contact Extractor do?

Paste a list of public company websites or domains, or select a dataset that contains their websites. The Actor reads each site and returns its best email, phone number and social profiles. The default `sites` output keeps one result per source website or row. It does not log in, crawl LinkedIn people, or discover companies from a directory.

### How to use

1. Replace the example entries in **Company websites or domains** with a few sites you want to check, one per line. Bare domains such as `hofbraeuhaus.de` work. The prefilled example list contains `katzsdelicatessen.com` and `hofbraeuhaus.de`.
2. Alternatively, select a **Source dataset ID** containing a website column. Clear the prefilled domain list if you want to process only the dataset. The engine finds common fields such as `Website`, `website`, `url` and `domain`; **Dataset field** can choose a different field.
3. Run the Actor and inspect the Dataset's **Bulk company website contacts** view. It shows `domain`, `bestEmail`, `primaryPhone`, `socialProfiles`, `charged` and `errorType`. The full Dataset includes source row details and other contact fields.

### Input example

For a spreadsheet or API workflow, `rows` also accepts objects such as:

```json
{
  "domains": [],
  "rows": [{ "Name": "Hofbräuhaus München", "Website": "hofbraeuhaus.de", "City": "München" }]
}
```

### Output example

The **Bulk company website contacts** view shows `domain`, `bestEmail`, `primaryPhone`, `socialProfiles`, `charged` and `errorType`. Rows with a usable website keep their row identifier in the full result. The view is a concise projection; export the full Dataset to inspect all columns.

### Pricing

The default `requiredContact: "any"` charges one `site-processed` event for a website with at least one email, phone number or social profile. The Free/Bronze event price in the engine is $0.002 per charged website; higher Apify tiers use the engine's tier prices. Each website is charged at most once even when more than one page is read. `outputMode: "sites"` is the default. Advanced JSON can select `emailRows`, but billing still stays per website.

A readable site with no qualifying contact has `charged: false`. Invalid lines, unreachable sites and sites whose home page is disallowed by robots.txt are free. With the default `respectRobotsTxt: true`, the crawler skips disallowed pages and returns `errorType: "robots_disallowed"` when its home page is disallowed. Recognized sign-in pages return a free `login_required` row. Requests identify the crawler; it does not log in, solve CAPTCHAs or bypass an explicit block. If robots.txt cannot be checked, see the full result and logs for the actual outcome rather than assuming a block was enforced.

### Limitations

Domains can be combined with either `rows` or a dataset. `rows` and `datasetId` cannot be combined.

#### Company names and other advanced inputs

This version starts with `findWebsitesForNames: false`. A company-name line in the domain list therefore returns a free `invalid_input` row. If you explicitly set `findWebsitesForNames: true` in advanced JSON, the original engine can look up a company's website using its name plus city, address or phone. An unverified lookup stays free (`company_not_found`). Other valid engine inputs, including `startUrls`, remain available in advanced JSON; this version does not reject them at runtime.

Use only public company websites you are entitled to process. A LinkedIn profile or other social page is not treated as the company's website.

### FAQ

**Can I enter company names instead of domains?** This version starts with `findWebsitesForNames: false`; a company-name line returns a free `invalid_input` row unless you explicitly enable the original engine's lookup in advanced JSON.

# Actor input Schema

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

Paste public company websites or domains, one per line (for example hofbraeuhaus.de). Bare domains are accepted; paths are extra pages and www variants count once. With the default name lookup off, company-name lines return free invalid_input rows. Up to 50,000 sites per run. You can also select a source dataset below.

## `datasetId` (type: `string`):

Select a readable dataset containing public company website/domain fields. The website field is detected automatically; use Dataset field if needed. Source rows without a usable website are free. This Actor reads the listed websites; it does not scrape a directory or Google Maps itself.

## `requiredContact` (type: `string`):

Choose which results are charged. "any" (default): a website with at least one email, phone number or social profile. "email": only websites where at least one email address is found are charged; websites with only a phone number or social profile are still returned, but free. The price per website is the same in both modes.

## `outputMode` (type: `string`):

"One row per email" splits each website into one row per email found, with the company name, primary phone, social profiles, the page where the email was found and whether it is on the website's own domain. The price is the same: each website is charged once (only its first row shows charged: true). A website with a phone or social profile but no email gives one row with email empty.

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

On by default: pages robots.txt disallows for TidyToolsContacts are skipped; a site whose home page is disallowed gets a free robots_disallowed row. If explicitly switched off, the engine may read user-supplied pages even where robots.txt disallows them. Requests still identify the crawler, do not log in or solve CAPTCHAs, and respect Retry-After.

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

The same as "Websites or company names", as a URL list; you can also upload or link a text/CSV file of websites. Bare domains belong in the list above (this editor rejects them).

## `findWebsitesForNames` (type: `boolean`):

Off by default: company-name lines are free invalid_input rows; supply websites or domains. Advanced JSON can explicitly set true to use the engine's verified company-name website lookup. Add city, address or phone for better matching; names without a verified site get free company_not_found rows.

## `namesCountry` (type: `string`):

2-letter country code (e.g. DE, GB, JP) so that company names are first tried on that country's domains (.de, .co.uk, .co.jp) before .com. Empty = .com, .io, .co.

## `datasetField` (type: `string`):

Field (or "rows" column) with the website or domain, e.g. "website" (dot paths such as "company.url" work). Empty = found automatically: website, then url, domain, link... A field that only holds Google Maps links (the "url" of a Maps place) is skipped, so Google Maps results need no setting.

## `datasetKeepFields` (type: `array`):

Extra fields copied from each dataset item into the output's "source" object, e.g. totalScore, reviewsCount. Google Maps results already get flat place columns (placeName, placeAddress, placePhone, mapsUrl, placeId, placeCategory, placeCity, placeCountryCode) without this setting.

## `rows` (type: `array`):

Instead of a dataset: the rows of your sheet as JSON objects, e.g. \[{"row_number": 2, "Website": "acme.com", "Email": ""}]. The website column is found automatically (Website, url, domain...) or set it in "Dataset field". Every row keeps its identifier (rowId) and gets its own output row, so you can write the result back to the same row.

## `rowIdField` (type: `string`):

Field that identifies each row of the dataset or "rows" (returned as rowId). Empty = the first of rowId, row_number, placeId, id; otherwise the row position. Branches of a chain that share one website are read once but keep their own rowId.

## `emailField` (type: `string`):

Field with the email you already have (Email, emails, primaryEmail... are found automatically). Used by "Only rows without an email" and for "fill": the found email is only offered for rows where this field is empty, so your own values are never overwritten.

## `phoneField` (type: `string`):

Field with the phone you already have (found automatically). The found phone is only put in "fill" when it is empty.

## `onlyMissingEmail` (type: `boolean`):

With a dataset or "rows": rows that already have an email are not read and not charged (they get one row with emailStatus already_had_email).

## `roleEmailsOnly` (type: `boolean`):

Off (default): every business email published on the website is returned, including addresses that look like a named person (anna.meier@...). On: only shared role mailboxes (info@, contact@, sales@, support@, office@...) are returned and used for billing; personal-looking addresses are dropped before output.

## `dedupeStoreName` (type: `string`):

Any name, e.g. "my-lead-list". Websites (by domain) read in an earlier run with the same name are not read or charged again: the earlier result is returned with fromPreviousRun. Kept in a key-value store "contacts-<name>" in your Apify account. Failed websites are not kept, so they are tried again. Empty = off.

## `dedupeMaxAgeDays` (type: `integer`):

With a dedupe name: results older than this are read again. 0 = no age limit.

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

The home page plus likely contact pages (contact, imprint/Impressum, about, team, support, legal). When those show no email, pages left over go to other pages likely to name a mailbox (policies, catering, jobs, locations...). The price per website is the same however many pages are read.

## `excludePaths` (type: `array`):

Optional JSON array of site paths beginning with /, e.g. {"excludePaths":\["/blog","/privacy"]}; enter one per line in the form. /blog excludes /blog/... but not /blogger; matching ignores case and query strings. Home pages and URLs you explicitly give are still read. Empty means no exclusion. A non-path entry causes a free invalid_input row and stops reading.

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

How many websites are processed at the same time (each reads up to 2 pages at once).

## `siteTimeoutSecs` (type: `integer`):

After this time no further pages of a website are started and the pages read so far are returned (skipped pages are listed in timedOutPages). If even the home page is not read by then, the website fails (not charged).

## `httpVia` (type: `string`):

Some sites block requests from data centers. Auto retries from a second network before falling back to a real browser. If those routes are refused, Auto also tries our second server (Oracle, different IP) before a real browser.

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

Only used for requests sent from Apify's network. Apify proxy usage is billed to your Apify account.

## Actor input object example

```json
{
  "domains": [
    "katzsdelicatessen.com",
    "hofbraeuhaus.de"
  ],
  "requiredContact": "any",
  "outputMode": "sites",
  "respectRobotsTxt": true,
  "findWebsitesForNames": false,
  "onlyMissingEmail": false,
  "roleEmailsOnly": false,
  "dedupeMaxAgeDays": 90,
  "maxPagesPerSite": 8,
  "maxConcurrency": 10,
  "siteTimeoutSecs": 120,
  "httpVia": "auto"
}
```

# Actor output Schema

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

No description

## `googleMaps` (type: `string`):

No description

## `full` (type: `string`):

No description

## `details` (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 = {
    "domains": [
        "katzsdelicatessen.com",
        "hofbraeuhaus.de"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("tidytools/website-contact-extractor-bulk").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": [
        "katzsdelicatessen.com",
        "hofbraeuhaus.de",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("tidytools/website-contact-extractor-bulk").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": [
    "katzsdelicatessen.com",
    "hofbraeuhaus.de"
  ]
}' |
apify call tidytools/website-contact-extractor-bulk --silent --output-dataset

```

## MCP server setup

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

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/YFFurB9PsbH0GAj7E/builds/HAPvSCSWNwJweP2Lt/openapi.json
