# Lead Data Cleaner, Deduper & Scorer (`hiredhandai/lead-data-cleaner`) Actor

Turn messy scraped leads into a clean, deduplicated, scored list. Chain after any Google Maps, LinkedIn or directory scraper. No scraping, no email sending, nothing stored.

- **URL**: https://apify.com/hiredhandai/lead-data-cleaner.md
- **Developed by:** [Logan Bishop](https://apify.com/hiredhandai) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.50 / 1,000 cleaned lead records

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

## Lead Data Cleaner, Deduper & Scorer

**Turn messy scraped leads into a clean, deduplicated list, and see exactly why each lead scored what it did.**

Point this Actor at any Apify dataset (Google Maps, LinkedIn, directory or custom scrapers) or paste a few records. It normalizes emails, phones and websites, merges duplicate businesses, flags disposable and role-based emails, and gives every lead a 0–100 quality score with the reasons spelled out. It never scrapes, sends email or connects to mail servers, and it keeps nothing after the run.

### What it does

- **Cleans the mess:** trims and collapses whitespace, repairs garbled accents (`CafÃ©` becomes `Café`), fixes ALL-CAPS and all-lowercase business names, strips tracking parameters (`utm_*`, `fbclid`, `gclid`) from websites, and upgrades links to `https`.
- **Validates emails and phone numbers:** checks email syntax, flags disposable domains and role accounts (`info@`, `sales@`), suggests fixes for common domain typos (`gmial.com` becomes `gmail.com`), and formats phones to international E.164 (`+15125550134`).
- **Removes duplicate leads:** merges records that share a website, phone number, business email or exact name + city, and optionally catches near-identical names in the same city. The merged record keeps the most complete data.
- **Scores every lead 0–100:** weighted by completeness and validity, with a plain-English list of reasons next to every score.
- **Checks email domains (optional):** a DNS lookup confirms the domain can receive mail. No SMTP probing, ever.

### How to use it

1. **Get your records.** Run any scraper and copy its dataset ID, or paste records as JSON.
2. **Run the Actor.** Set **Dataset ID** (or **Records**), pick a **Scoring profile**, and click Start. Field names such as `title`, `email`, `phone`, `website` and `address` are detected automatically.
3. **Use the clean list.** Download it as CSV, Excel or JSON from the Dataset tab, or read it through the API.

#### Chain it after any scraper

JavaScript (`apify-client`):

```js
import { ApifyClient } from 'apify-client';

const client = new ApifyClient({ token: process.env.APIFY_TOKEN });

// 1. Run your scraper (replace with the Actor and input you use)
const scraperRun = await client.actor('<your-scraper>').call({ /* scraper input */ });

// 2. Clean its dataset
const cleanRun = await client.actor('hiredhandai/lead-data-cleaner').call({
  datasetId: scraperRun.defaultDatasetId,
  scoreProfile: 'local-business',
});

// 3. Read the cleaned, scored leads
const { items } = await client.dataset(cleanRun.defaultDatasetId).listItems();
console.log(items.filter((lead) => lead.score >= 70));
```

Python (`apify-client`):

```python
import os
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])

clean_run = client.actor("hiredhandai/lead-data-cleaner").call(
    run_input={"datasetId": "YOUR_DATASET_ID", "defaultCountry": "GB"}
)
for lead in client.dataset(clean_run["defaultDatasetId"]).iterate_items():
    if lead["score"] >= 70:
        print(lead["name"], lead["email"], lead["score"])
```

It also works as a step in Make, n8n, Zapier and Google Sheets flows: run your scraper, then pass its `defaultDatasetId` to this Actor with the Apify integration of your tool.

### Input

| Field | Type | Default | Description |
|---|---|---|---|
| `datasetId` | string | | Apify dataset to clean. Pick it in the Console, or pass its ID through the API. Use this **or** `records`. |
| `records` | array | | Up to 10,000 inline records (small jobs, API use). |
| `fieldMap` | object | auto-detect | Map your field names to `name`, `email`, `phone`, `website`, `address`, `city`, `country`, `category`. Only needed for unusual names, e.g. `{"Company": "name"}`. |
| `defaultCountry` | string | `US` | Two-letter code used to read phone numbers written without a country prefix. |
| `dedupe` | `fuzzy` / `exact` / `off` | `fuzzy` | How aggressively to merge duplicates. |
| `checkMx` | boolean | `true` | DNS check that each email's domain can receive mail. |
| `scoreProfile` | `general` / `b2b-outreach` / `local-business` | `general` | Which signals matter most for your use case. |
| `dropInvalid` | boolean | `false` | Remove records scoring below `minScore` instead of just labelling them. |
| `minScore` | integer 0–100 | `0` | Used with `dropInvalid`. |
| `maxRecords` | integer | | Optional cap on how many input records are read. |

### Output

One item per surviving record in the default dataset. Real output from the prefilled example input (three messy records, two of them the same business):

```json
{
  "name": "Acme Plumbing LLC",
  "email": "info@acmeplumbing.com",
  "emailStatus": "valid_syntax",
  "emailIsRole": true,
  "emailIsDisposable": false,
  "phone": "+15125550134",
  "phoneStatus": "valid",
  "website": "https://www.acmeplumbing.com",
  "domain": "acmeplumbing.com",
  "city": "Austin",
  "country": "US",
  "score": 60,
  "scoreReasons": ["+10 name", "+25 valid email", "+20 valid phone", "+15 website", "-10 role email"],
  "dupeGroupId": "g_1",
  "isPrimaryOfGroup": true,
  "dupeCount": 1,
  "sourceIndex": 0
}
```

| Field | Meaning |
|---|---|
| `emailStatus` | `valid_syntax`, `valid_syntax_mx_ok` (domain can receive mail), `valid_syntax_no_mx`, `invalid_syntax` or `missing` |
| `emailSuggestion` | Corrected address when the domain looks like a typo (`joe@gmial.com` gives `joe@gmail.com`). The original is left unchanged. |
| `phoneStatus` | `valid`, `possible` (plausible length, unverified), `invalid` or `missing` |
| `score`, `scoreReasons` | 0–100 and every contribution behind it |
| `dupeGroupId`, `dupeCount` | The duplicate group and how many other records were merged into this one |
| `sourceIndex` | Position of the kept record in your input |

A run summary is saved to the key-value store as `OUTPUT`: records in and out, duplicates merged, invalid emails and phones, average score.

### How scoring works

The score is deterministic and open, so you can trust and explain it. For the **General** profile:

| Signal | Points |
|---|---|
| Business name present | +10 |
| Valid email | +25 (+5 more if the domain accepts mail) |
| Valid phone | +20 (+8 if only "possible") |
| Website | +15 |
| Address | +10 |
| Category | +5 |
| Name, email, phone, website and address all present and valid | +10 |
| Role email (`info@`, `sales@`) | −10 |
| Disposable email | −30 |
| Email domain has no mail server | −10 |

**B2B outreach** weights email and a verified mail domain most and penalizes role and disposable emails harder. **Local business** weights phone and address most and is relaxed about role emails. Scores are capped to 0–100.

### Pricing

Pay per event, no subscription:

- **$1.50 per 1,000 cleaned records** (`record-cleaned`, $0.0015 each). You are charged for records returned, so duplicates that were merged away are free.
- A tiny start fee charged by Apify on every run (about $0.00005).

| Job | Records returned | Cost |
|---|---|---|
| 500 scraped leads with typical duplicates (about 400 unique) | 400 | about $0.60 |
| 10,000 unique leads | 10,000 | about $15 |
| 100,000 unique leads | 100,000 | about $150 |

You can set a maximum cost per run in the run options; the Actor stops cleanly and tells you how many records were not returned. Plan prices and platform usage are covered in the [Apify pricing](https://apify.com/pricing).

### Limits

- Up to **100,000 records per run** (10,000 inline). Records larger than 100 KB are skipped and counted in the run summary.
- Speed measured on Apify (1 GB): 500 records in about 3 seconds; 10,000 records with live email-domain checks in about a minute (turn off `checkMx` for seconds). Results depend on how fast the email domains answer DNS.

### FAQ

**Does it send emails?** No. It never sends anything.

**Does it check whether an inbox exists (SMTP)?** No. It only does a DNS lookup of the email's domain. A valid-looking address can still bounce, and the score says "valid email", not "deliverable guaranteed".

**What happens to my data?** It is processed during the run and written to your own run dataset. The Actor stores nothing else and sends your data nowhere. The only network traffic is DNS lookups of email domain names (not addresses) when `checkMx` is on.

**Does it enrich or look up missing data?** No. It only cleans, merges and scores what you give it. No external data sources are used.

**How does it decide two records are duplicates?** Same website domain, same phone number, same business email (free-mail and disposable addresses are ignored), or the same name and city. In fuzzy mode it also merges near-identical names in the same city, but never when each name has a word the other lacks ("Red Rock Bakery" and "Red Rock Realty") or when numbers differ ("Store 1000" and "Store 1001").

**Can I clean a CSV?** Upload it as a dataset in Apify, or convert it to JSON and paste it into `records`.

**Which countries work?** Any country supported by the phone library. Set `defaultCountry` so national numbers parse correctly.

### Support

Questions, bugs or feature requests: use the **Issues** tab on this Actor, or email <leadcleaner@lbishop.app>. I answer within 48 hours.

If this saved you time, a rating on the Actor page helps others find it.

# Actor input Schema

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

The dataset to clean, for example the output of a Google Maps or LinkedIn scraper run. Pick it from your datasets, or pass its ID when calling the Actor through the API. Leave empty if you paste records below.

## `records` (type: `array`):

Up to 10,000 records as a JSON array of objects. Use this for small jobs or API calls; use Dataset ID for bigger ones. Common field names (title, email, phone, website, address, city, country, category) are detected automatically.

## `fieldMap` (type: `object`):

Only needed if your records use unusual field names. Map your field to one of: name, email, phone, website, address, city, country, category. Example: `{"Company": "name", "Tel": "phone"}`.

## `defaultCountry` (type: `string`):

Two-letter country code used to read phone numbers written without a country prefix, for example US, GB or DE.

## `dedupe` (type: `string`):

Fuzzy also catches near-identical names in the same city ("Acme Plumbing" vs "Acme Plumbng LLC"). Exact matches only identical website, phone, email or name + city. Duplicates are merged into one record that keeps the most complete data.

## `checkMx` (type: `boolean`):

Looks up whether each email's domain can receive mail. This is a DNS check only; the Actor never connects to mail servers or sends anything.

## `scoreProfile` (type: `string`):

What a good lead means to you. Every score lists the reasons behind it.

## `dropInvalid` (type: `boolean`):

Remove records that score below the minimum score instead of just labelling them.

## `minScore` (type: `integer`):

Used only when "Drop low-scoring records" is on. 0 to 100.

## `maxRecords` (type: `integer`):

Optional safety cap on how many input records are read. The Actor processes at most 100,000 records per run.

## Actor input object example

```json
{
  "records": [
    {
      "title": "ACME PLUMBING LLC",
      "email": "INFO@AcmePlumbing.com",
      "phone": "(512) 555-0134",
      "website": "http://www.acmeplumbing.com/?utm_source=maps",
      "city": "Austin",
      "country": "us"
    },
    {
      "title": "Acme Plumbing",
      "emails": [
        "info@acmeplumbing.com"
      ],
      "website": "acmeplumbing.com",
      "city": "austin"
    },
    {
      "title": "Joe's Pizza",
      "email": "joe@gmial.com",
      "phone": "call us"
    }
  ],
  "defaultCountry": "US",
  "dedupe": "fuzzy",
  "checkMx": true,
  "scoreProfile": "general",
  "dropInvalid": false,
  "minScore": 0
}
```

# Actor output Schema

## `leads` (type: `string`):

One item per surviving lead with normalized fields, a 0-100 score and the reasons behind it.

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

Counts of records in and out, duplicates merged, invalid emails and phones, and average score.

# 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 = {
    "records": [
        {
            "title": "ACME PLUMBING LLC",
            "email": "INFO@AcmePlumbing.com",
            "phone": "(512) 555-0134",
            "website": "http://www.acmeplumbing.com/?utm_source=maps",
            "city": "Austin",
            "country": "us"
        },
        {
            "title": "Acme Plumbing",
            "emails": [
                "info@acmeplumbing.com"
            ],
            "website": "acmeplumbing.com",
            "city": "austin"
        },
        {
            "title": "Joe's Pizza",
            "email": "joe@gmial.com",
            "phone": "call us"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("hiredhandai/lead-data-cleaner").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 = { "records": [
        {
            "title": "ACME PLUMBING LLC",
            "email": "INFO@AcmePlumbing.com",
            "phone": "(512) 555-0134",
            "website": "http://www.acmeplumbing.com/?utm_source=maps",
            "city": "Austin",
            "country": "us",
        },
        {
            "title": "Acme Plumbing",
            "emails": ["info@acmeplumbing.com"],
            "website": "acmeplumbing.com",
            "city": "austin",
        },
        {
            "title": "Joe's Pizza",
            "email": "joe@gmial.com",
            "phone": "call us",
        },
    ] }

# Run the Actor and wait for it to finish
run = client.actor("hiredhandai/lead-data-cleaner").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 '{
  "records": [
    {
      "title": "ACME PLUMBING LLC",
      "email": "INFO@AcmePlumbing.com",
      "phone": "(512) 555-0134",
      "website": "http://www.acmeplumbing.com/?utm_source=maps",
      "city": "Austin",
      "country": "us"
    },
    {
      "title": "Acme Plumbing",
      "emails": [
        "info@acmeplumbing.com"
      ],
      "website": "acmeplumbing.com",
      "city": "austin"
    },
    {
      "title": "Joe'\''s Pizza",
      "email": "joe@gmial.com",
      "phone": "call us"
    }
  ]
}' |
apify call hiredhandai/lead-data-cleaner --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,hiredhandai/lead-data-cleaner"
        }
    }
}
```

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/I4W6O0WJMQIqkFo7T/builds/75b2pmBl25JRzR1h1/openapi.json
