I built an Apify actor called Website Contact Extractor, so treat my opinions with that in mind. An AI assistant helped draft this article and I reviewed and edited it. The free script comes first because for a few sites it is enough.
The problem
You have a list of business websites and need a way to reach them: a published email, a phone number, a social profile. Doing it by hand means opening the homepage, hunting for a Contact link, and copying values into a sheet. That scales to about twenty sites before it hurts.
The free route
Businesses that want to be contacted usually publish details in predictable places: mailto: and tel: links on the homepage and on a /contact page, and sometimes structured data in a JSON-LD block (schema.org Organization or LocalBusiness). The script below checks all three, and honors robots.txt before fetching.
import json, re, requests
from urllib.parse import urljoin
from urllib.robotparser import RobotFileParser
UA = "contact-check/0.1 (you@example.com)"
def contacts(base):
rp = RobotFileParser(urljoin(base, "/robots.txt"))
rp.read()
out = {"emails": set(), "phones": set(), "jsonld": []}
for path in ("/", "/contact"):
url = urljoin(base, path)
if not rp.can_fetch(UA, url):
continue
r = requests.get(url, timeout=15, headers={"User-Agent": UA})
if r.status_code != 200:
continue
html = r.text
out["emails"] |= {m.split("?")[0] for m in
re.findall(r'href=["\']mailto:([^"\']+)', html, re.I)}
out["phones"] |= set(re.findall(r'href=["\']tel:([^"\']+)', html, re.I))
for blk in re.findall(r'<script[^>]+ld\+json[^>]*>(.*?)</script>',
html, re.S | re.I):
try:
out["jsonld"].append(json.loads(blk))
except ValueError:
pass
return out
print(contacts("https://example.com"))
Politeness matters more than cleverness here:
- Read
robots.txt(the script does) and skip what it disallows. - Identify yourself in the User-Agent, with a way to reach you.
- Fetch one page at a time per site, with a delay when you loop over many pages.
- Only collect what the business published for contact purposes. A named person's email on a team page can be personal data under rules like GDPR, so think about what you store and why. This is a general caution, not legal advice.
What the free route does not do
The script finds the easy cases and nothing more:
-
Finding the right pages.
/contactis a guess. Real sites use/contact-us,/impressum,/about,/locationsor a footer link with some other name. You need link-text scoring to find them. -
Phone formats.
tel:values come as(313) 555-0142,313.555.0142or+1 313.... Turning them into one comparable format needs a phone-number library and a country assumption. -
Obfuscated and text emails. Addresses written as
info [at] example.com, or just shown as text rather than amailto:link, are missed. -
Role versus named.
info@andjane.doe@are different kinds of lead, and the script does not tell them apart. - Social profiles and address. You would need more regexes and JSON-LD parsing for each.
- Dead sites. Parked domains, DNS failures, bot challenges and timeouts need handling and reporting, or your results silently go missing.
- Change monitoring. Knowing when a site's published contacts changed means keeping state between runs.
The actor
The actor wraps those problems into one run. You give it websites or bare domains and get one row per site. It fetches the homepage over plain HTTP (no browser), then the best contact, impressum, about, team and privacy pages it finds by link text and URL, up to a configurable cap. It respects robots.txt, fetches pages within one site one after another, and does not use logins or captcha bypass.
Example input:
{
"startUrls": [{ "url": "https://buddyspizza.com" }],
"maxPagesPerSite": 5,
"defaultCountry": "US",
"includeNamedEmails": true,
"onlyWithContacts": false,
"maxSites": 100
}
Output excerpt from a real run (fields trimmed; the full row also has per-source detail and a flat copy of each social field):
{
"domain": "buddyspizza.com",
"status": "ok",
"companyName": "Buddy's Pizza",
"companyNameSource": "schema.org",
"emails": [
{ "email": "buddyspizza@buddyspizza.com", "type": "role",
"foundOn": "https://buddyspizza.com/privacy-policy/",
"pageType": "privacy", "method": "mailto" }
],
"primaryEmail": "buddyspizza@buddyspizza.com",
"phones": [
{ "phone": "+18009650505", "national": "(800) 965-0505", "pageType": "home", "method": "tel-link" },
{ "phone": "+18334510774", "national": "(833) 451-0774", "pageType": "contact", "method": "tel-link" }
],
"socials": {
"facebook": "https://www.facebook.com/buddyspizza",
"instagram": "https://www.instagram.com/buddyspizzadet",
"x": "https://x.com/buddyspizza"
},
"contactPageUrl": "https://buddyspizza.com/contact/",
"pagesCrawled": 4
}
Notice where the email came from: the privacy page, not the homepage. That is the kind of thing a two-URL script misses. Each email and phone records the page it was found on and the method (mailto link, tel link and so on), so you can verify any value.
Other options: monitorMode remembers each site's emails, phones and socials between runs and returns only sites that changed; urlIncludePatterns and urlExcludePatterns steer the crawl; sameDomainOnly is on by default.
Limits and pricing
- It finds only contacts the site publishes. It does not guess addresses and it does not verify that an email is deliverable.
- A site that loads but publishes no email is still charged, because it was crawled. The row will still show whatever phones or socials exist.
- Dead, blocked, parked and robots-disallowed sites come back with a
statusand are not charged. - Pricing is pay per event: $0.002 per site crawled plus $0.00005 per run start, so 1,000 sites is about $2.00 whether it crawls one page or five. Volume tiers are scheduled for paid Apify plans; check the Pricing tab for the current figure.
- Named personal emails can be personal data. Use the output in line with the laws that apply to you.
Actor page: https://apify.com/transparent_meteorite/website-contact-extractor
Disclosure again: I'm the builder of this actor, and an AI assistant helped draft this article; I reviewed it before publishing.
Top comments (0)