# Website Contact Scraper | Emails, Phones & Social Links (`signalfoundryhq/website-contact-extractor`) Actor

Extract published website emails, phones and social links with source URLs. Optional email-only rows before charging; n8n and Python CSV setup guides included. $3 per 1,000 delivered page versions; no start fee.

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

## Pricing

from $2.55 / 1,000 delivered contact page versions

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 Scraper | Emails, Phones & Social Links

Turn a list of public domains or company contact-page URLs into a spreadsheet with source URLs. Each delivered row contains the emails, phone numbers and social profile links found on one page. Contacts are extracted from HTML; mailboxes are not verified.

Choose your setup: [Apify Console](#start-with-your-use-case), [n8n to CSV](#export-contacts-to-csv-with-n8n), or [Python to CSV](#export-contacts-to-csv-with-python).

Paste entries such as `example.com`, `example.com/contact` or `https://example.com/contact`. Domains without a scheme use HTTPS; there is no automatic fallback to HTTP. Duplicate forms of the same normalized URL are fetched once. Existing HTTPS inputs keep the same record identities for repeat-run history.

### Start with your use case

| Use case | Preview the input | Copy setup to Apify |
| --- | --- | --- |
| Research software agencies | [Software agency public contact pages](https://apify.com/signalfoundryhq/website-contact-extractor/examples/software-agency-public-contact-pages) | [Copy agency setup](https://console.apify.com/create-task-from-example/Xk343K9YZBcVrGOee) |
| Build a SaaS contact spreadsheet | [SaaS public contact pages to CSV](https://apify.com/signalfoundryhq/website-contact-extractor/examples/saas-public-contact-pages-csv) | [Copy SaaS setup](https://console.apify.com/create-task-from-example/atBxojYo68dQznNr4) |
| Review a hosting provider's published contacts | [Hosting provider public contact audit](https://apify.com/signalfoundryhq/website-contact-extractor/examples/hosting-provider-public-contact-audit) | [Copy hosting setup](https://console.apify.com/create-task-from-example/QIdmDldMouGdOqOoF) |

These inputs use public pages as examples. Website content and access rules can change. Replace their URLs with the websites you want to research.

#### From example to CSV

1. Choose **Copy setup** and sign in to Apify, then review the editable input. For a first run, keep one or two domains or URLs, set `maxPagesPerSite` to `1` and `maxItems` to `10`. Leave `includeEmptyPages` and `onlyNew` off.
2. Set **maximum charge to $0.03** in the run options and start the Actor. The base price is **$0.003 per delivered page version**: ten rows cost $0.03, including platform usage. There is no start fee; eligible Apify plans have lower event prices. A spending cap does not guarantee ten results.
3. When the run finishes, open its dataset and export **CSV** or **JSON**. In Console, select the columns below if you want a compact spreadsheet. Keep `pageUrl` so each contact can be checked at its source.

| Output field | What it contains |
| --- | --- |
| `websiteUrl` | The starting URL from your input |
| `pageUrl` | The page where the contacts were found |
| `pageTitle` | The page's HTML title, when present |
| `emails` | An array of email addresses found in visible HTML text or `mailto:` links |
| `phones` | An array of phone numbers found in `tel:` links or labeled text |
| `socialLinks` | An array of supported social profile links |

One page can contain several contacts in the same row. By default, a row may contain only a phone number or a social link, with an empty `emails` array. Enable **Only pages with an email** (`requireEmail: true`) to omit those rows before delivery and result charging. Use JSON if you want to preserve these arrays directly.

For more sample inputs, try [Python and Django contact pages](https://apify.com/signalfoundryhq/website-contact-extractor/examples/python-django-public-contact-pages) or the [project foundation watchlist](https://apify.com/signalfoundryhq/website-contact-extractor/examples/project-foundation-contact-watchlist).

### Real result preview

These two page records came from an actual Actor run on **2026-10-02**, using the project foundation watchlist with one page per website. Selected published role inboxes and the number of extracted social profile links are shown; other fields and contacts are omitted here.

| Source page | Selected role inboxes from `emails` | Entries in `socialLinks` |
| --- | --- | --- |
| [Python help](https://www.python.org/about/help/) | `psf@python.org`, `webmaster@python.org` | 6 |
| [Django Software Foundation contact](https://www.djangoproject.com/contact/foundation/) | No email extracted | 7 |

With the default `requireEmail: false`, both are billable page records: a page with only social links can still be delivered. With `requireEmail: true`, only the Python help row qualifies for delivery; the Django page has no extracted email. The example produced two rows, not one row per individual contact. Website content can change, and extracted email addresses are not mailbox-verified. [Copy the preview's source-page setup](https://console.apify.com/create-task-from-example/L6TDDK6geozsSxi7b) to review the input before starting your own run.

### Keep only page records containing email

Enable **Only pages with an email** in Input, or set `requireEmail: true` in an API call:

```json
{
  "websiteUrls": [
    "https://www.djangoproject.com/contact/foundation/",
    "www.python.org/about/help/"
  ],
  "requireEmail": true,
  "maxPagesPerSite": 1,
  "maxItems": 1
}
```

No-email pages do not enter the dataset, generate a result charge or consume `maxItems`. A social-only homepage can still lead to an email-bearing contact/about page when `maxPagesPerSite` allows it. `requireEmail` takes precedence over `includeEmptyPages`. The setting is off by default, so existing input and repeat-run identities stay compatible.

This filter checks whether an email was extracted; it does not validate mailbox existence, ownership or permission to send mail. A row remains one page version with an email array, not one paid result per address. If none of the examined pages contains an email, the dataset is empty. Pages behind JavaScript rendering, contact forms, access restrictions or your page limit may contain contacts this Actor does not see.

### What you can do

- Maintain a business directory from public contact pages.
- Prepare a source-linked contact research spreadsheet.
- Refresh an existing company watchlist and skip unchanged contact versions.

### Pricing

The base price is **$0.003 per delivered page version ($3 per 1,000 rows)**, with plan discounts where shown in Apify. A row can include multiple contacts; the price is per delivered row. Identical contact sets repeated on several pages of the same website are emitted only once per run. Pages with no extracted contacts are omitted by default and produce no result charge. If you enable `includeEmptyPages`, delivered empty-page rows are charged too. When `requireEmail: true`, pages without an extracted email are omitted and have no result charge, even if `includeEmptyPages` is also true.

There is no start fee. Apify platform usage is included in this Actor's per-event price. Unchanged versions skipped with `onlyNew` are not charged. Set a maximum charge in Apify Console or use the API `maxTotalChargeUsd` parameter.

### Quick start

Paste this input in the Actor and choose Start:

```json
{
  "websiteUrls": [
    "www.python.org",
    "https://www.djangoproject.com"
  ],
  "maxPagesPerSite": 3,
  "includeEmptyPages": false,
  "maxItems": 10
}
```

For repeated calls, enable onlyNew and reuse stateKey for the same feed. To replay records, change the key or use resetState. History retains the last 25,000 delivered fingerprints; older versions may be delivered again after eviction. Changing your filters with the same key does not clear history. onlyNew scans up to the source scan limit before skipping remembered versions, so repeated runs may read more source records than they deliver. maxItems caps delivered rows.

### Export contacts to CSV with n8n

Use the importable workflow or manual recipe below with n8n's built-in nodes; it needs no community-node installation or Google Sheets permission. Connect **Manual Trigger → HTTP Request → Code → Convert to File**. Keep one trigger item and put the complete website list in one request body: one POST starts one paid Actor run.

#### Import the ready-made workflow

[Download the n8n workflow JSON](https://api.apify.com/v2/key-value-stores/r6lAZrhwAonfiOTwh/records/contacts-to-csv.n8n.json?signature=1hZUoUI2U389K1ZIkD7VZ). In n8n, open the workflow menu, choose **Import from URL**, and paste that link. You can also save the JSON and choose **Import from File**.

The template contains four connected built-in nodes and is inactive. Select a private **Header Auth** credential on **Extract contacts — max $0.03**: header name `Authorization`, value `Bearer YOUR_APIFY_TOKEN`. Edit `websiteUrls` in that node's JSON body before executing. No token, credential ID or contact results are included in the shared file.

Each manual execution starts one new paid snapshot, with a **$0.03** server-side cap and automatic retries disabled. Review the input and current pricing before running it. A lost response may still have started a run; check Apify Console before trying again. Empty results may produce no CSV file.

The JSON settings and embedded transformation were checked locally against the current node definitions; import and execution in a live n8n instance have not been verified. It uses HTTP Request 4.2, Code 2 and Convert to File 1.1. The manual recipe below is available if your instance needs different node settings.

#### 1. Configure the HTTP Request

In a new HTTP Request node, choose **Import cURL** and paste this command. It intentionally contains no API token:

```bash
curl --request POST \
  --url 'https://api.apify.com/v2/acts/signalfoundryhq~website-contact-extractor/run-sync-get-dataset-items?maxTotalChargeUsd=0.03&memory=256&timeout=120' \
  --header 'Content-Type: application/json' \
  --data '{"websiteUrls":["www.python.org/about/help/","https://www.djangoproject.com/contact/foundation/"],"maxPagesPerSite":1,"maxItems":10,"includeEmptyPages":false,"requireEmail":true,"onlyNew":false,"stateKey":"n8n-csv-export"}'
```

After import, set the body to **JSON → Using JSON**, then paste the object below. n8n's cURL importer can import values as strings; this step preserves the required numbers and booleans.

```json
{
  "websiteUrls": [
    "www.python.org/about/help/",
    "https://www.djangoproject.com/contact/foundation/"
  ],
  "maxPagesPerSite": 1,
  "maxItems": 10,
  "includeEmptyPages": false,
  "requireEmail": true,
  "onlyNew": false,
  "stateKey": "n8n-csv-export"
}
```

For authentication, select **Generic Credential Type → Header Auth**. Store the header name `Authorization` and value `Bearer YOUR_APIFY_TOKEN` in a private n8n credential, replacing the placeholder with your own Apify token. Keep the token out of shared workflow JSON and request URLs.

Set **Response Format** to **JSON**, **Timeout** to `150000` milliseconds, and keep **Include Response Headers and Status**, **Never Error**, pagination and **Retry On Fail** off. Connect the Manual Trigger directly to this node; do not loop the POST once per website or incoming contact.

The query caps this run at **$0.03**, with a 120-second Actor timeout. At the current base price of $0.003 per delivered page version, ten rows cost $0.03 including platform usage; Store plan discounts can lower that price. `requireEmail: true` excludes no-email page rows before result charging. The cap does not guarantee ten rows, and the extractor does not verify mailboxes or find private executive emails.

#### 2. Flatten contact arrays

Set the Code node to **JavaScript → Run Once for All Items** and paste:

```javascript
// n8n Code node: JavaScript, Run Once for All Items.
const fields = ['websiteUrl', 'pageUrl', 'pageTitle', 'emails', 'phones', 'socialLinks'];

function spreadsheetCell(value) {
  const text = Array.isArray(value)
    ? value.map(String).join('; ')
    : value == null ? '' : String(value);
  const visible = text.replace(/^[\s\uFEFF]+/, '');
  return /^[=+\-@＝＋－＠]/.test(visible) || /^[\t\r\n]/.test(text)
    ? "'" + text
    : text;
}

return $input.all().map((item, index) => {
  const row = item.json;
  if (!row || typeof row.pageUrl !== 'string' || !Array.isArray(row.emails)) {
    throw new Error('Expected a contact dataset row. Check the HTTP response and keep full-response mode off.');
  }
  return {
    json: Object.fromEntries(fields.map(field => [field, spreadsheetCell(row[field])])),
    pairedItem: { item: index },
  };
});
```

This keeps one row per delivered page version, joins each contact array into one semicolon-separated cell, and retains its source URLs. Formula-like cells receive a leading apostrophe, including phone numbers beginning with `+`; that prefix changes the exported string. Import untrusted spreadsheet columns as text and keep the original JSON when you need unmodified phone numbers or arrays.

#### 3. Download the CSV

Set Convert to File to **Convert to CSV**, put the output file in `data`, use `contacts.csv` as the file name, and enable **Header Row**. Execute the manual workflow after checking the input and charge cap, then download the output file. An empty result array can stop the downstream nodes without producing a file; check the Actor run's dataset and logs before trying again.

Each execution starts a new snapshot run because this example uses `onlyNew: false`; repeating it can charge for the same records again. If n8n times out or loses the response, inspect recent runs in Apify Console and retrieve the existing dataset before repeating the POST. For jobs beyond the synchronous window, use the asynchronous run API and fetch the dataset when that same run finishes.

References: [n8n HTTP Request and cURL import](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.httprequest), [private HTTP credentials](https://docs.n8n.io/integrations/builtin/credentials/httprequest), [Code node](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.code), [Convert to File](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.converttofile).

### Export contacts to CSV with Python

#### Download the Python client

[Python client (.py)](https://api.apify.com/v2/key-value-stores/rN22CqrBNCVJqCj4j/records/export_contacts.py?signature=11bso9ZjwncY61i506xEm) · [Synthetic sample dataset (.json)](https://api.apify.com/v2/key-value-stores/rN22CqrBNCVJqCj4j/records/sample_contacts.json?signature=165MPjDuYyPI6bsOXFvsc)

Save both linked files in one folder. Try the client without an API token, network request or new Actor run:

```bash
python3 export_contacts.py --from-json sample_contacts.json --output contacts.csv
```

This writes two CSV rows from synthetic `example.com` page records. Add `--require-email` to write one email-bearing row from that same file. These are demonstration records, not collected leads. To scrape real websites, follow the paid-run instructions below. The complete client is also included below for copying.

Save the complete script below as `export_contacts.py`. It uses Python 3.9+ and the standard library. Put one public domain or contact-page URL per line in a UTF-8 file called `sites.txt`:

```text
www.python.org/about/help/
https://www.djangoproject.com/contact/foundation/
```

Set `APIFY_TOKEN` to your own Apify API token, then run:

```bash
python3 export_contacts.py --websites sites.txt --output contacts.csv --max-charge 0.03 --max-items 10 --max-pages 1
```

This starts a new paid Actor run. The default cap is $0.03, with up to ten delivered page versions and one page examined per website. The base price is $0.003 per delivered page version; plan discounts can lower it. The cap and row limit do not guarantee ten results. Repeating this snapshot command starts another run and can charge for the same records again.

For an email-bearing-pages-only CSV, add `--require-email`:

```bash
python3 export_contacts.py --websites sites.txt --require-email --output contacts-with-email.csv --max-charge 0.03 --max-items 10 --max-pages 1
```

For a new run, the flag sends `requireEmail: true` to the Actor, so no-email rows are removed **before result charging**, rather than hidden afterwards in your CSV. If your starting page has no email, increase `--max-pages` to examine linked contact/about pages; this does not guarantee a match.

If you already downloaded the dataset as JSON, convert that existing file without a new Actor run or API token:

```bash
python3 export_contacts.py --from-json dataset.json --output contacts.csv
```

You can also add `--require-email` to this offline command. It filters the existing JSON locally and cannot change any charges from the original run.

The CSV preserves source-page URLs and stores each contact array in one semicolon-separated cell. It includes a UTF-8 BOM for common spreadsheet imports. Cells that could begin spreadsheet formulas receive a leading apostrophe, including phone numbers starting with `+`; that prefix is part of the exported data. Spreadsheet behavior varies, especially after saving and reopening a CSV. Import untrusted columns as text and retain the original JSON for automated processing. See [OWASP's CSV injection guidance](https://community.owasp.org/attacks/CSV_Injection).

The HTTP client sends one POST with a server-side run charge limit and no automatic retries. If the connection fails, inspect the Actor's recent runs in Console before retrying; a broken response does not prove that no run started. This example uses a 120-second Actor timeout and a 150-second client timeout. For longer jobs, use the [asynchronous run API](https://docs.apify.com/api/v2/actor-run-post) and retrieve its dataset after completion.

```python
#!/usr/bin/env python3
"""Export Contact Scraper rows to a spreadsheet-oriented UTF-8 CSV."""
import argparse
import csv
from decimal import Decimal, InvalidOperation
import json
import os
from pathlib import Path
import socket
import sys
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen

ACTOR = 'signalfoundryhq~website-contact-extractor'
FIELDS = ('websiteUrl', 'pageUrl', 'pageTitle', 'emails', 'phones', 'socialLinks')


def positive_usd(text):
    try:
        value = Decimal(text)
    except InvalidOperation:
        raise argparse.ArgumentTypeError('Use a positive USD amount.')
    if not value.is_finite() or value <= 0:
        raise argparse.ArgumentTypeError('Use a positive USD amount.')
    return value


def run_actor(websites, token, max_charge, max_pages=1, max_items=10, *, require_email=False, build='latest'):
    if not 1 <= len(websites) <= 25:
        raise ValueError('Provide 1 to 25 non-empty websites or contact-page URLs.')
    if not 1 <= max_pages <= 10 or not 1 <= max_items <= 250:
        raise ValueError('Use 1–10 pages per site and 1–250 delivered rows.')
    if not token:
        raise ValueError('Set APIFY_TOKEN to your own Apify API token.')
    query = urlencode({'maxTotalChargeUsd': format(max_charge, 'f'), 'memory': 256, 'timeout': 120, 'build': build})
    payload = {'websiteUrls': websites, 'maxPagesPerSite': max_pages, 'maxItems': max_items,
               'includeEmptyPages': False, 'onlyNew': False, 'stateKey': 'csv-export',
               'requireEmail': require_email}
    request = Request(
        f'https://api.apify.com/v2/acts/{ACTOR}/run-sync-get-dataset-items?{query}',
        data=json.dumps(payload).encode('utf-8'), method='POST',
        headers={'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'})
    # Do not automatically retry a paid POST: an interrupted response can hide a live run.
    with urlopen(request, timeout=150) as response:
        rows = json.load(response)
    if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
        raise ValueError('Expected a JSON array of dataset records; no CSV was written.')
    if require_email and any(not row.get('emails') for row in rows):
        raise ValueError('The run returned a row without email despite requireEmail. Check the run and selected build in Console before retrying; no CSV was written.')
    return rows


def spreadsheet_cell(value):
    if value is None:
        text = ''
    elif isinstance(value, list):
        text = '; '.join(str(item) for item in value)
    else:
        text = str(value)
    # Quoting alone does not prevent spreadsheet formula interpretation.
    visible = text.lstrip(' \t\r\n\ufeff')
    if visible.startswith(('=', '+', '-', '@', '＝', '＋', '－', '＠')) or text.startswith(('\t', '\r', '\n')):
        return "'" + text
    return text


def write_csv(rows, output):
    if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
        raise ValueError('Expected an array of dataset records.')
    with Path(output).open('w', encoding='utf-8-sig', newline='') as handle:
        writer = csv.DictWriter(handle, fieldnames=FIELDS, quoting=csv.QUOTE_ALL)
        writer.writeheader()
        for row in rows:
            writer.writerow({key: spreadsheet_cell(row.get(key)) for key in FIELDS})


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__)
    source = parser.add_mutually_exclusive_group(required=True)
    source.add_argument('--websites', type=Path, help='UTF-8 text file, one domain or URL per line')
    source.add_argument('--from-json', type=Path, help='Convert an existing dataset JSON array without a new run')
    parser.add_argument('--output', type=Path, default=Path('contacts.csv'))
    parser.add_argument('--max-charge', type=positive_usd, default=Decimal('0.03'))
    parser.add_argument('--max-pages', type=int, default=1)
    parser.add_argument('--max-items', type=int, default=10)
    parser.add_argument('--require-email', action='store_true', help='Keep only pages containing an extracted email; applied before result charging for a new run')
    args = parser.parse_args(argv)
    try:
        if args.from_json:
            rows = json.loads(args.from_json.read_text(encoding='utf-8-sig'))
            if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
                raise ValueError('Expected an array of dataset records.')
            if args.require_email:
                rows = [row for row in rows if row.get('emails')]
        else:
            sites = [line.strip() for line in args.websites.read_text(encoding='utf-8-sig').splitlines() if line.strip()]
            rows = run_actor(sites, os.environ.get('APIFY_TOKEN', '').strip(), args.max_charge,
                             args.max_pages, args.max_items, require_email=args.require_email)
        write_csv(rows, args.output)
    except (HTTPError, URLError, TimeoutError, socket.timeout) as error:
        status = f' (HTTP {error.code})' if isinstance(error, HTTPError) else ''
        print(f'Request did not finish{status}. Check this Actor in Apify Console before retrying; a run may already exist. No automatic retry.', file=sys.stderr)
        return 1
    except (OSError, ValueError) as error:
        print(str(error), file=sys.stderr)
        return 1
    print(f'Wrote {len(rows)} page records to {args.output}')
    return 0


if __name__ == '__main__':
    raise SystemExit(main())
```

### API and export

POST your input to https://api.apify.com/v2/acts/signalfoundryhq~website-contact-extractor/runs?maxTotalChargeUsd=0.05 using an Apify token in the Authorization header. After the run succeeds, read defaultDatasetId from the run and retrieve /v2/datasets/{datasetId}/items. Apify also provides CSV and other exports in Console. OUTPUT contains delivered, duplicatesSkipped and eventChargeLimitReached. Never put tokens in public URLs.

For a compact CSV, request the dataset items with these parameters, using the same Authorization header:

```text
GET https://api.apify.com/v2/datasets/{datasetId}/items?format=csv&clean=true&fields=websiteUrl,pageUrl,pageTitle,emails,phones,socialLinks
```

Every record includes signalId, recordFingerprint, source, collectedAt and a source page/API URL. Metadata changes create new fingerprints. No background schedule is created by this Actor.

### If you get no rows or fewer rows than expected

- **Start with the actual contact page.** With `maxPagesPerSite: 1`, only the starting page is fetched. Use its contact URL directly, or increase the page limit to follow same-origin contact/about/support links.
- **Check whether the page exposes contacts in HTML.** Forms without a published address, contacts inserted by JavaScript and obfuscated addresses may produce no email results. The Actor does not guess addresses or bypass access controls.
- **Check run status and logs.** A robots exclusion, denied request or non-HTML starting page can fail the run. A successful run can still have an empty dataset when no contacts match.
- **Check duplicate history.** With `onlyNew` enabled, `OUTPUT.duplicatesSkipped` shows remembered versions that were omitted. Turn `onlyNew` off to export them again; delivered rows are charged normally.
- **Check the limits.** `maxItems`, `maxPagesPerSite` and the maximum charge can stop delivery early. `OUTPUT.eventChargeLimitReached` identifies a result-charge limit. Repeated contact sets within a website are collapsed into one row.

### Scope and limitations

Server-rendered HTML only. No browser rendering, login, email guessing, mailbox verification or obfuscation bypass. Contact/about/support links are followed only on the current origin. Default: 3 fetched pages per site; maximum: 25 sites × 10 pages, 250 delivered page rows. Robots exclusions are respected, and private/reserved addresses are rejected. Plain-text phone extraction can be imperfect; values retain the page URL for review. A contact record does not imply permission to contact someone.

Source documentation: [Public website HTML](https://www.rfc-editor.org/rfc/rfc9309). SignalFoundry is independent of the source platform.

### Related tool

For product prices, SKUs and stock flags from public WooCommerce catalogs, use [WooCommerce Products Scraper](https://apify.com/signalfoundryhq/woocommerce-product-radar). It is a separate Actor priced at $0.0015 per delivered product version.

### Support

Open an issue on this Actor with the run URL and a non-sensitive description of the problem. Do not include tokens, passwords or private data.

# Actor input Schema

## `websiteUrls` (type: `array`):

1 to 25 public domains or HTTP(S) website/contact-page URLs. Bare domains and domain/path entries use HTTPS. Private addresses, credentials and custom ports are rejected.

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

Start page and same-origin contact/about links. Robots.txt is respected.

## `requireEmail` (type: `boolean`):

Deliver and charge only page records containing at least one extracted email. Pages without email still lead to contact/about links within the page limit. Overrides Include pages without contacts; mailboxes are not verified.

## `includeEmptyPages` (type: `boolean`):

When false, pages without an email, phone or social link are omitted and not charged. Only pages with an email takes precedence when enabled.

## `stateKey` (type: `string`):

Reuse a key for one watchlist; change it for another.

## `onlyNew` (type: `boolean`):

Skip previously delivered versions. No duplicate charge; no deletion events or field-level diffs.

## `resetState` (type: `boolean`):

Clear saved fingerprints and replay matching current records.

## `maxItems` (type: `integer`):

One row per fetched contact-bearing page version. Multiple details are included in one row.

## Actor input object example

```json
{
  "websiteUrls": [
    "https://www.python.org",
    "https://www.djangoproject.com"
  ],
  "maxPagesPerSite": 3,
  "requireEmail": false,
  "includeEmptyPages": false,
  "stateKey": "default",
  "onlyNew": false,
  "resetState": false,
  "maxItems": 100
}
```

# Actor output Schema

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

// Run the Actor and wait for it to finish
const run = await client.actor("signalfoundryhq/website-contact-extractor").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("signalfoundryhq/website-contact-extractor").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 signalfoundryhq/website-contact-extractor --silent --output-dataset

```

## MCP server setup

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

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/4BOaPLj5wYUOKek3Z/builds/xM5CvOgrekYqLdEJq/openapi.json
