~ / guides / How to Scrape Emails & Leads (Python Guide)

How to Scrape Emails & Leads (Python Guide)

MR
Marcus Reed
Founder & lead tester · about the author
the short version
  • An email scraper pulls addresses out of page HTML. Two reliable methods: a regex over the visible text, and parsing mailto: links. I ran both live below.
  • Against the W3C contact page my regex scraper returned 10 addresses; the mailto parser pulled 8 from the GNU contact page. Real status codes and output are pasted verbatim.
  • Most lead pages obfuscate addresses (name [at] domain), render them with JavaScript, or block bot user-agents. That is where a scraper API like ChocoData earns its place.
  • Collecting public addresses is largely lawful in the US after hiQ v. LinkedIn. Sending to them is a separate compliance project under CAN-SPAM and GDPR.

An email scraper extracts addresses out of the HTML of pages you point it at. It is the backbone of cold-outreach lead lists, recruiting pipelines, and partner research. This guide builds one in Python with requests and BeautifulSoup, shows the two extraction methods that actually hold up (a regex over the text, and parsing mailto: links), and is honest about the three things that break naive scrapers: obfuscation, JavaScript, and bot blocks. I ran both methods against live public pages in June 2026, and the output below is pasted verbatim.

Can you legally scrape emails and leads?

Collecting addresses from public pages is largely lawful in the US, and sending to them is governed separately. The split trips people up: getting the address and using the address answer to different laws.

On collection, the key precedent is hiQ Labs v. LinkedIn, where the Ninth Circuit held in 2022 that automated capture of data from publicly accessible pages (ones that need no login) does not violate the Computer Fraud and Abuse Act’s “without authorization” clause. The practical caveat: hiQ still lost on other grounds, settling and agreeing to destroy the scraped data, so breach-of-contract and other claims stay in play. If any address belongs to an EU resident, GDPR governs collection regardless of where you scrape: Article 6 requires a lawful basis, and Article 83 sets fines up to EUR 20 million or 4% of worldwide annual turnover.

On sending, the rules tighten by region. The table summarizes the lines for cold email:

RegimeCoversCore rule for cold emailHeadline penalty
CAN-SPAM (US, FTC)Commercial email to US recipientsOpt-out model: honor unsubscribes, no false headers, label as ad, include a postal addressUp to $53,088 per email (FTC compliance guide)
GDPR (EU)Personal data of EU residentsNeed a lawful basis to process the address at allUp to 4% of global turnover
ePrivacy / CNIL (France, B2C)Email to individualsPrior opt-in consent requiredPer GDPR tiers
ePrivacy / CNIL (France, B2B)Professional addressesNo prior consent, but recipient can object and pitch must relate to their jobPer GDPR tiers

Two specifics. The CAN-SPAM maximum is a per-email figure in the FTC’s own compliance guide, so a bad blast scales fast. And France draws a sharp B2C/B2B line: generic addresses (info@, contact@) need no consent at all, professional addresses can be emailed if the recipient can object, individuals must opt in first. None of this is legal advice; if you are scaling outreach, run your process past counsel. For the wider picture see my is web scraping legal overview, and check each address before you send, not after.

What lead data can you actually pull from a page?

Public pages expose more than the address, and a good scraper grabs the surrounding fields in the same pass. Here is what is realistically available and how reliably you get it:

FieldWhere it livesHow reliableNotes
Email addressVisible text or mailto: hrefHigh on contact/team pagesThe core target; often obfuscated
Person nameHeading or card next to the emailMediumPair by DOM proximity
Job title / roleTeam-page card, bylineMediumInconsistent markup across sites
Phone numberContact block, footerMediumRegex similar to email
Social handlesa[href] to linkedin/x/etcHigh when presentCheap to grab alongside email
Department / inbox typeThe local part (sales@, hr@)Highinfo@/contact@ are role inboxes, not people
Company / domainThe URL you are onHighFree context for every row

The honest limit: a contact page usually hands you role inboxes (sales@, support@), not named decision-makers. Team and “about” pages are where personal addresses surface, and those are also where obfuscation is heaviest. Plan your target list accordingly.

How do pages block or hide their emails?

Most lead-bearing pages fight scrapers in one of three ways, and each defeats a naive regex. Knowing which one you are facing tells you which method to reach for.

DefenseWhat you seeBeats a plain regex?Fix
Text obfuscationname [at] domain dot com, entities, imagesYesNormalize [at]/(dot) before matching
JavaScript injectionAddress absent from raw HTML, present in browserYesRender with Playwright, or use a scraper API
mailto: onlyNo address in visible textPartlyParse the href attribute, not the text
Bot user-agent block403/429 or a stripped pageN/AReal User-Agent, slow rate, rotate IPs
Login wallRedirect to sign-inN/AOut of scope; do not bypass auth

The two methods below cover clean text and mailto: links, the cases you can solve with requests alone. Obfuscation, JavaScript, and IP blocks push you toward Playwright or a scraper API, which I cover at the end. Before scraping anything, read the site’s robots.txt and respect it. The W3C page I test below allows it: its robots.txt disallows only WordPress admin paths.

Method 1: How do you build an email scraper with Python and BeautifulSoup?

Fetch the page, run a regex for the email pattern over the parsed text, and dedupe. This is the fastest method and it works whenever the address is present as plain text in the HTML. I pointed it at the W3C contact page, a stable public page that lists its team inboxes. Here is the exact code I ran:

import re
import requests
from bs4 import BeautifulSoup

EMAIL_RE = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")
HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; lead-research/1.0)"}

url = "https://www.w3.org/Consortium/contact"
resp = requests.get(url, headers=HEADERS, timeout=20)
print("STATUS:", resp.status_code)

soup = BeautifulSoup(resp.text, "html.parser")
text = soup.get_text(" ", strip=True)
emails = sorted(set(EMAIL_RE.findall(text)))

print("FOUND:", len(emails))
for e in emails:
    print("-", e)

The real output:

STATUS: 200
FOUND: 10
- contact@w3.org
- giving@w3.org
- invoicing@w3.org
- membership@w3.org
- site-comments@w3.org
- sysreq@w3.org
- team-china-contact@w3.org
- team-liaisons@w3.org
- team-wcap-contact@w3.org
- w3t-pr@w3.org

Three details make this robust. I run the regex over soup.get_text() rather than resp.text, which strips script and style blocks so I do not match addresses buried in tracking code. The set() dedupes, because the same inbox often appears in both a link and the footer. And I set a real User-Agent: many sites serve a stripped page or a block to the default python-requests agent.

The regex itself is deliberately simple and will catch obvious false positives like you@example.com in sample text, plus version strings that look address-like. For a lead list, filter the results: drop addresses whose domain does not match the site you are on, and discard known placeholder domains (example.com, sentry.io, your-domain). The pattern also misses obfuscated addresses on purpose, which the next method and the scraper-API section address.

Parse the href of every mailto: anchor instead of reading the visible text. Many sites wrap the address in a link and never print it as plain text, so a text regex alone returns nothing while the address sits right there in the markup. I ran this against the GNU contact page, which uses mailto: links throughout:

import requests
from urllib.parse import unquote
from bs4 import BeautifulSoup

HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; lead-research/1.0)"}
url = "https://www.gnu.org/contact/"
resp = requests.get(url, headers=HEADERS, timeout=20)
print("STATUS:", resp.status_code)

soup = BeautifulSoup(resp.text, "html.parser")
emails = []
for a in soup.select("a[href^=mailto]"):
    addr = unquote(a["href"][7:].split("?")[0])  # strip 'mailto:' and ?subject=
    if addr:
        emails.append(addr)

emails = sorted(set(emails))
print("FOUND:", len(emails))
for e in emails:
    print("-", e)

The real output:

STATUS: 200
FOUND: 8
- assign@gnu.org
- gnu@gnu.org
- license-violation@gnu.org
- licensing@gnu.org
- maintainers@gnu.org
- sysadmin@gnu.org
- web-translators@gnu.org
- webmasters@gnu.org

The slice a["href"][7:] drops the mailto: prefix, .split("?")[0] trims any ?subject= query that a link carries, and unquote decodes URL-encoded characters. In production, run both methods on every page and merge the results: the regex catches plain-text addresses, the mailto: parser catches linked ones, and the union is your row. If you want to learn the selector mechanics behind a[href^=mailto], my CSS selectors guide covers attribute matching in depth, and the BeautifulSoup guide covers parsing end to end.

How do you crawl multiple pages and dedupe a real lead list?

Maintain a set of seen addresses, loop your target URLs, run both extractors on each, and sleep between requests. A single contact page is a demo; a lead list means visiting many. Here is the pattern, combining both methods into one reusable function:

import re, time, requests
from urllib.parse import unquote
from bs4 import BeautifulSoup

EMAIL_RE = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")
HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; lead-research/1.0)"}

def emails_from(url):
    r = requests.get(url, headers=HEADERS, timeout=20)
    if r.status_code != 200:
        return set()
    soup = BeautifulSoup(r.text, "html.parser")
    found = set(EMAIL_RE.findall(soup.get_text(" ", strip=True)))
    for a in soup.select("a[href^=mailto]"):
        found.add(unquote(a["href"][7:].split("?")[0]))
    return {e for e in found if e}

targets = [
    "https://www.w3.org/Consortium/contact",
    "https://www.gnu.org/contact/",
]

leads = set()
for url in targets:
    leads |= emails_from(url)
    time.sleep(2)          # be polite: one request every 2 seconds

print(f"{len(leads)} unique addresses across {len(targets)} pages")

Three production notes. The time.sleep(2) keeps you under most rate limits; tune it, but do not remove it. The if r.status_code != 200 guard means a single blocked page does not crash the run. And feeding the union of both extractors into one set gives you free deduplication across the whole crawl. For a larger, structured crawl with retries and queues, graduate to Scrapy, which handles concurrency and politeness for you.

Method 2: When should you use a scraper API instead?

Use a scraper API once your targets obfuscate addresses, render them with JavaScript, or block your IP after a few requests, because that is exactly when requests and BeautifulSoup stop returning data. The W3C and GNU pages above are well-behaved. Real lead targets (large directories, marketplaces, JS-heavy team pages, anything behind aggressive bot detection) are not. At scale your IP gets a 403, the address never appears in the raw HTML because a script injects it, or a CAPTCHA blocks the page entirely. For why this happens and the wider defenses, see scraping without getting blocked.

A scraper API solves the transport layer: it rotates residential proxies, clears CAPTCHAs, and renders JavaScript, handing you the final HTML so your extraction code stays simple. ChocoData exposes a universal endpoint that, per its own pages in mid-2026, works across 235 sites, plus 453 dedicated endpoints in total. You call the universal endpoint with a target URL, it returns rendered HTML, and you run the same two extractors from above against the response. Here is the shape (add your key to run it):

import re, requests
from bs4 import BeautifulSoup

EMAIL_RE = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")

resp = requests.get(
    "https://api.chocodata.com/v1/universal",
    params={
        "api_key": "YOUR_KEY",
        "url": "https://example.com/team",
        "render_js": "true",          # execute JavaScript before returning HTML
    },
    timeout=60,
)
soup = BeautifulSoup(resp.text, "html.parser")
emails = sorted(set(EMAIL_RE.findall(soup.get_text(" ", strip=True))))
print(emails)

The extraction logic is identical to Method 1; the only change is that the API delivers HTML that did not block you and that already ran its scripts. On pricing, from ChocoData’s own pricing page in mid-2026:

PlanPriceRequests/moCredits/mo
Free$01,0005,000
Vibe$19/mo27,000135,000
Pro$49/mo82,000410,000
Custom$100-$2,000/mo200k-4M1M-20M

One standard request is 5 credits; JavaScript rendering adds +10. Pay-as-you-go top-ups run $0.90 per 1,000 successful requests, and ChocoData states only successful (2xx) responses are billed, so block pages do not cost you. The free tier needs no card. ChocoData is a general scraper API, not an email database: it gives you the rendered HTML at scale, and you still parse and verify the addresses yourself with the code above.

Should you scrape pages or buy from a finder database?

Match the approach to your target. If your leads are businesses with a known name and domain, a finder database (Hunter, Apollo, Snov.io) returns a scored, often pre-verified address in one call and is cleaner than crawling. If your leads live on arbitrary sites no database has indexed (regional directories, niche marketplaces, freshly published pages), you scrape, with requests and BeautifulSoup for clean pages and a scraper API for blocked or JS-heavy ones. I compare the named finder and crawler tools, their prices, and free tiers in best email scrapers, so I will not re-rank them here.

Whichever path you take, the address is the cheap part. Scraping returns syntactically valid strings, not confirmed mailboxes, so budget real effort for verification (syntax, MX lookup, SMTP probe) and treat the send as a separate compliance project under CAN-SPAM and GDPR. Get the extraction right with the two methods above, verify before you send, and keep a documented lawful basis for any EU address. For the fundamentals behind all of it, headers, pagination, parsing, and etiquette, start with the web scraping pillar guide.

FAQ

Can I scrape Gmail or personal inboxes for emails?

No. This guide scrapes public web pages (contact pages, directories, team listings) where an organization has chosen to publish an address. Accessing a private inbox like Gmail requires the account owner's credentials or OAuth consent, and bypassing that is unauthorized access, not scraping. Stick to addresses published on public pages.

Why does my email regex miss addresses that are clearly on the page?

Three common reasons. The address is obfuscated as 'name [at] domain dot com' to defeat exactly your regex. It is injected by JavaScript after load, so it is absent from the raw HTML requests sees. Or it sits inside a mailto: link attribute, not the visible text, so parse the href too. Handle all three and your hit rate jumps.

How do I verify scraped emails are deliverable?

Scraping gives you syntactically valid strings, not confirmed mailboxes. Run them through a separate verifier: a syntax check, an MX-record DNS lookup on the domain, then an SMTP probe. Tools like Hunter and Snov.io bundle verification; for a DIY list, validate the domain has MX records before you ever send, and expect 10 to 30 percent of harvested generic addresses to bounce.

MR
Marcus Reed
I've built and run web scrapers for the better part of a decade. On this site I put scraper APIs and scraping tools through real jobs against real targets, then write up what actually holds up.