← All posts

How to Extract Contact Info From Company Websites

extract contact info from company websites

Quick answer: To extract contact info from a company website, target the pages where it actually lives (contact, about, team, footer, legal/imprint pages), fetch them as clean markdown, then run a two-pass extraction: regex for emails, phones, and social URLs, followed by an LLM pass to attach names and roles and catch obfuscated formats. Normalize the results into your CRM schema with a source URL and timestamp per field. And treat compliance as part of the pipeline, not an afterthought: business contact data is still personal data under GDPR.

I've built this pipeline for CRM enrichment more than once. The extraction is the easy half; finding the right pages and staying compliant is where the real work is. Here's the whole thing, start to finish.

Step 1: find the pages that actually have contact info

Don't crawl the whole site. Contact info concentrates in a handful of predictable places:

  • /contact, /contact-us, /about, /about-us, /team, /people
  • The footer of the homepage (phone, address, socials are almost always there)
  • /imprint or /impressum (legally required in Germany and Austria, and a goldmine: company name, address, email, often a named managing director)
  • /legal, /privacy (a compliance contact email at minimum)

A cheap discovery pass: fetch the homepage, collect internal links whose URL or anchor text matches those patterns, and fetch just those. Five pages per domain covers the large majority of companies. If you also want news-page contacts and bylines, an article scraper is the adjacent tool for that job.

Step 2: fetch as markdown, not HTML

Markdown fetching does three jobs at once here: it strips the noise, it renders JS (so contact info injected client-side actually appears), and it decodes Cloudflare's email protection. That last one matters: on many sites, what looks like an email in the browser is [email protected] with a hex-encoded data-cfemail attribute in the raw HTML, and naive scrapers silently harvest nothing.

import requests

def fetch_md(url: str) -> str:
    resp = requests.get(
        "https://link.sc/v1/fetch",
        headers={"Authorization": "Bearer lsc_your_key"},
        params={"url": url, "format": "markdown"},
        timeout=60,
    )
    resp.raise_for_status()
    return resp.json()["markdown"]

Because link.sc renders the page before converting, JS-rendered and Cloudflare-obfuscated addresses come through as plain text. If you're rolling your own instead, you'll need a headless browser for these cases; raw requests.get will miss a meaningful slice of contacts and you won't know it.

Step 3: first pass, regex

Regex is fast, free, and catches the well-formed majority.

import re

EMAIL = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")
PHONE = re.compile(r"\+?[\d][\d\s().-]{7,}\d")
SOCIAL = re.compile(
    r"https?://(?:www\.)?(linkedin\.com/(?:company|in)/[\w-]+"
    r"|twitter\.com/\w+|x\.com/\w+|github\.com/[\w-]+)"
)
OBFUSCATED = re.compile(
    r"([\w.+-]+)\s*(?:\[at\]|\(at\)|\s@\s|\bat\b)\s*([\w-]+)"
    r"\s*(?:\[dot\]|\(dot\)|\bdot\b)\s*(\w{2,})", re.I,
)

def regex_pass(md: str) -> dict:
    emails = set(EMAIL.findall(md))
    emails |= {f"{u}@{d}.{tld}" for u, d, tld in OBFUSCATED.findall(md)}
    return {
        "emails": sorted(emails),
        "phones": [p.strip() for p in PHONE.findall(md)],
        "socials": sorted(set(SOCIAL.findall(md))),
    }

Two caveats from experience. Phone regexes over-match (they'll grab order numbers and dates), so validate with a library like phonenumbers before trusting them. And human obfuscation ("sales at acme dot com", "firstname.lastname@ our domain") comes in more flavors than any regex list, which is why there's a second pass.

Step 4: second pass, LLM

The LLM pass does what regex can't: it attaches emails and phones to people and roles, decodes creative obfuscation, and distinguishes "sales contact" from "the webmaster email in the footer."

import json
from openai import OpenAI

client = OpenAI()

PROMPT = """Extract contact information from this page as JSON:
{"company": str|null, "address": str|null,
 "contacts": [{"name": str|null, "role": str|null,
               "email": str|null, "phone": str|null}],
 "generic_emails": [str], "socials": [str]}
Decode obfuscated emails (e.g. "jane [at] acme [dot] com").
Only include information present on the page. Use null when absent."""

def llm_pass(md: str) -> dict:
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        response_format={"type": "json_object"},
        messages=[
            {"role": "system", "content": PROMPT},
            {"role": "user", "content": md[:30000]},
        ],
    )
    return json.loads(resp.choices[0].message.content)

Then reconcile: anything the regex found but the LLM didn't gets kept (LLMs occasionally drop items), and anything the LLM produced that doesn't match the email regex after decoding gets flagged rather than trusted. LLMs will hallucinate a plausible [email protected] if you let them, so the rule is: the LLM may only promote strings that exist in the source text.

Step 5: normalize into CRM shape

Raw extraction isn't enrichment until it's normalized. The shape that has worked for me:

{
  "domain": "acme.com",
  "company_name": "Acme GmbH",
  "address": "Musterstrasse 1, 10115 Berlin",
  "contacts": [
    {"name": "Jane Doe", "role": "Head of Sales",
     "email": "[email protected]", "phone": "+49 30 1234567"}
  ],
  "generic_emails": ["[email protected]"],
  "socials": {"linkedin": "linkedin.com/company/acme"},
  "source_urls": ["https://acme.com/contact", "https://acme.com/imprint"],
  "extracted_at": "2026-07-18T09:00:00Z"
}

Non-negotiables: lowercase and dedupe emails, normalize phones to E.164 with phonenumbers, and keep source_urls and extracted_at on every record. Provenance is what lets you answer "where did this contact come from?" later, and under GDPR you may be obligated to answer exactly that question.

The compliance section (not optional)

This is the part that separates a legitimate enrichment pipeline from a spam operation, so let me be direct.

GDPR applies to business contacts. [email protected] is personal data. You need a lawful basis to process it (typically legitimate interest, which requires an actual balancing assessment), you must honor deletion and access requests, and under Article 14 people generally have a right to know you're processing their data. info@ and sales@ addresses are safer ground because they identify a role, not a person.

CAN-SPAM and friends govern what you send. In the US, cold B2B email is legal with accurate headers, a physical address, and a working unsubscribe. Canada's CASL and many EU member states are stricter, some effectively requiring consent for electronic outreach. Where your recipient sits determines the rules.

Public does not mean free-for-all. Don't harvest from platforms whose terms prohibit it (LinkedIn profile scraping is its own legal minefield), don't extract personal contacts from contexts where people clearly didn't publish them for sales outreach, and delete on request the first time, without argument.

My working rule: extract what companies deliberately publish on their own contact and about pages, keep provenance for everything, and be the kind of sender whose unsubscribe link works. For the broader legal picture, see is web scraping legal, and for the operational side, ethical scraping and compliance in 2026.

Putting it together

The full pipeline is about 150 lines: discover pages, fetch as markdown, regex pass, LLM pass, reconcile, normalize, store with provenance. At link.sc pricing, the free tier's 500 monthly credits cover roughly 100 companies at five pages each, which is enough to validate the whole approach before you spend anything. The quickstart will get you to your first markdown fetch in a couple of minutes.


Ready to enrich your CRM from live websites instead of stale databases? Grab a free link.sc API key and run the pipeline above today.