Web Page Contact Extractor

Build a Python tool that scrapes contact info from web pages into a CSV.

Introduction

30 Second Summary

Every business website has a "Team" or "Contact" page with names, emails, and phone numbers scattered across it. Copying that information by hand is slow and error-prone.

In this project, you will build a Python script that automatically fetches a web page, scans its HTML for contact details, and saves everything to a clean CSV file.

What You'll Build

A command-line Python program that takes a URL as input, fetches the web page, and extracts contact information (names, job titles, email addresses, and phone numbers) into a structured CSV file.

By the end of this project, you'll have:

  • A working Python script (contact_extractor.py) that fetches web pages and extracts contact data using regex patterns and BeautifulSoup selectors.
  • Reusable extraction functions that find emails, phone numbers, names, and titles from raw HTML.
  • Structured CSV output with columns for Name, Title, Email, and Phone.
  • Secret Mission: extend your tool to crawl multiple linked pages from the same domain and combine all results into a single file.

Are there any prerequisites?

Basic familiarity with running Python scripts is helpful. This project walks you through setting up everything else from scratch.

Before We Start

To build a contact extractor, you need a Python environment with a few key libraries installed. Python handles the logic, and the libraries handle fetching web pages and parsing their HTML.

In this step, you will install Python, set up a project folder with its own isolated environment, and install two packages: requests for fetching web pages and beautifulsoup4 for parsing HTML.

In this step, get ready to:
  • Install Python 3.10+ and verify the installation.
  • Create a project folder and set up a virtual environment.
  • Install the required packages: requests and beautifulsoup4.
Install Python and VS Code

This project requires Python 3.10 or higher. Let's check if you already have it installed.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Check your Python version by running this command:

macOS

python3 --version

Windows

python --version

✔️ I see version 3.10 or higher

Your Python version is ready to go. Skip ahead to installing VS Code below.

ⓧ I see an older version

You have Python installed, but it is below version 3.10. The requests library requires Python 3.10 or higher, so you need to install a newer version.

  • Go to python.org/downloads and download the latest Python installer for your operating system.
  • Run the installer.
  • On Windows, make sure to check Add python.exe to PATH before clicking Install Now.
  • Close and reopen your terminal, then run the version check command again.

macOS users with an older default Python

macOS often ships with an older Python version by default. After installing a newer Python from python.org, the python3 command should point to your new installation.

If it still shows an old version, try running python3.12 --version or python3.13 --version to confirm the new install is available.

ⓧ Command not found

Python is not installed on your machine yet.

  • Go to python.org/downloads and download the latest Python installer for your operating system.
  • Run the installer.
  • On Windows, make sure to check Add python.exe to PATH before clicking Install Now.
  • Close and reopen your terminal, then run the version check command again to confirm.

Now let's get VS Code, the code editor you will use for this project.

  • If you already have VS Code installed, open it by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Visual Studio Code, and pressing Enter.
  • If you do not have it yet, download it from code.visualstudio.com, install it, then open it using the search method above.

What is VS Code?

Visual Studio Code is a free, lightweight code editor that runs on macOS, Windows, and Linux. It has a built-in terminal, which means you can write code and run commands in the same window.

Create your project folder and virtual environment

A virtual environment keeps your project's packages separate from other Python projects on your machine. This prevents version conflicts between projects.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, top-left of your keyboard).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir contact-extractor
cd contact-extractor
  • Confirm you are inside the folder by running:
pwd

You should see a path ending in contact-extractor.

  • Create a virtual environment by running this command:

macOS

python3 -m venv venv

Windows

python -m venv venv
  • Activate the virtual environment by running:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

What is a virtual environment?

A virtual environment is an isolated Python installation folder inside your project. When it is active, any packages you install with pip go into that folder instead of your system-wide Python.

This means you can have different versions of the same library across different projects without them conflicting.

Not seeing (venv) in your prompt?

Make sure you ran the activate command for your operating system. On macOS it starts with source. On Windows it uses a backslash path.

If you get a permissions error on Windows, try running:

Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned

Then try the activate command again. Help me activate my virtual environment.

Install the required packages

With your virtual environment active, you can now install the two libraries your extractor needs.

  • Install requests and beautifulsoup4 by running:
pip install requests beautifulsoup4

What do these packages do?

requests lets Python fetch web pages over HTTP. You give it a URL and it returns the page's HTML content.

beautifulsoup4 (imported as bs4) parses that HTML into a navigable tree structure. This makes it easy to search for specific tags, classes, and text content.

You should see output ending with a line that says Successfully installed followed by the package names and version numbers.

  • Verify the packages installed correctly by running:
pip show requests beautifulsoup4

You should see details for both packages, including their names and version numbers.

Seeing 'pip: command not found'?

Make sure your virtual environment is active (you should see (venv) in your prompt). If the environment is active and you still get this error, try using pip3 instead of pip.

Help me install packages in my virtual environment.

  • Open the contact-extractor folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the contact-extractor folder, and click Open.

VS Code should now show the contact-extractor folder in the Explorer sidebar on the left. You will see the venv folder listed there.

Your environment is ready. Next up, you will write a function that fetches a live web page and brings back its HTML for parsing.

Set Up Your Python Environment

Your contact extractor needs a foundation before it can fetch pages and find patterns. That foundation is a script file with the right imports and the regular expressions that will detect emails and phone numbers in raw HTML.

In this step, you'll create the main script file and define the regex patterns that power extraction.

In this step, get ready to:
  • Create contact_extractor.py with the required imports.
  • Define regex patterns for detecting emails and phone numbers.
Open your project folder in VS Code

Your project folder already has a virtual environment with requests and beautifulsoup4 installed. Now you need to open it in VS Code so you can start writing code.

  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select your project folder (the one containing your virtual environment), then click Open.

You should see your project folder name in the Explorer sidebar on the left, with your virtual environment folder listed inside it.

Create the script and add imports

Your extractor needs several libraries working together. The requests library fetches web pages, BeautifulSoup parses HTML structure, and Python's built-in re module handles pattern matching.

  • In the Explorer sidebar, hover over your project folder name and click the New File icon (a page with a plus sign).
  • Type contact_extractor.py and press Enter to create the file.
  • Add the import block by pasting this code into the new file:
import re
import csv
import sys
from urllib.parse import urljoin

import requests
from bs4 import BeautifulSoup

What do these imports do?

  • re is Python's built-in regex module for pattern matching in text.
  • csv handles writing structured data to CSV files.
  • sys lets you read command-line arguments (like the URL to scrape).
  • urljoin from urllib.parse resolves relative URLs into full addresses.
  • requests fetches web pages over HTTP.
  • BeautifulSoup from bs4 parses HTML into a navigable tree structure.
  • Save contact_extractor.py.
  • In your terminal (with the virtual environment active), run the file to confirm all imports resolve:
python contact_extractor.py

You should see no output and no errors. This confirms all packages are installed and importable.

Seeing a ModuleNotFoundError?

Make sure your virtual environment is active. You should see its name in parentheses at the start of your terminal prompt (e.g. (venv)). If not, activate it again before running the file.

If the error mentions requests or bs4, reinstall the packages with:

pip install requests beautifulsoup4

help me fix a ModuleNotFoundError when running my Python script

Define regex patterns for emails and phone numbers

Regular expressions

Regular expressions are text patterns that describe what a string looks like. Your extractor will scan raw HTML for anything that matches an email or phone number shape.

  • In contact_extractor.py, add the regex pattern constants below your imports:
# Regex patterns
EMAIL_PATTERN = re.compile(
    r'[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}'
)

PHONE_PATTERN = re.compile(
    r'(\+?1[\s.\-]?)?'
    r'\(?\d{3}\)?[\s.\-]?'
    r'\d{3}[\s.\-]?'
    r'\d{4}'
)

What do these patterns match?

EMAIL_PATTERN looks for the standard email shape: characters before an @ symbol, followed by a domain with at least one dot and a 2+ character extension (like .com or .org).

PHONE_PATTERN matches US phone numbers with an optional country code (+1) and flexible separators. It handles formats like (555) 123-4567, 555.123.4567, and 555-123-4567.

  • Save contact_extractor.py.
  • Run the file again in your terminal:
python contact_extractor.py

No output and no errors means your regex patterns compiled successfully.

Seeing a regex syntax error?

Check that each re.compile() call has matching parentheses. The phone pattern uses multiple raw strings joined together. Make sure each line inside the PHONE_PATTERN block starts with r' and ends with '.

help me fix a regex compilation error in my Python script

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the full contact_extractor.py so far:

import re
import csv
import sys
from urllib.parse import urljoin

import requests
from bs4 import BeautifulSoup


# Regex patterns
EMAIL_PATTERN = re.compile(
    r'[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}'
)

PHONE_PATTERN = re.compile(
    r'(\+?1[\s.\-]?)?'
    r'\(?\d{3}\)?[\s.\-]?'
    r'\d{3}[\s.\-]?'
    r'\d{4}'
)

Your script has its foundation in place. Next up, you'll write the function that fetches a web page so your extractor has real HTML to work with.

Fetch a Web Page

Before your script can extract contacts, it needs to download the web page's HTML. Every website you visit in a browser is just an HTML document sent over HTTP.

In this step, you'll write a function that does the same thing your browser does. It sends an HTTP request to a URL and brings back the raw HTML for your extraction code to process.

In this step, get ready to:
  • Write a fetch_page function using the requests library.
  • Add a User-Agent header and error handling for failed requests.
  • Test the function against a sample URL.
Write the fetch_page function

Your extractor needs a reliable way to download a web page. The function you'll write handles three important concerns: identifying itself to the server with a User-Agent header, setting a timeout so the script doesn't hang forever, and catching network errors gracefully.

  • In contact_extractor.py, find the PHONE_PATTERN block at the bottom of your file.
  • Add the following function below the regex patterns by copying this code:
def fetch_page(url):
    """Fetch a web page and return its HTML content."""
    headers = {
        'User-Agent': 'ContactExtractor/1.0 (Learning Project)'
    }
    try:
        response = requests.get(url, headers=headers, timeout=10)
        response.raise_for_status()
        return response.text
    except requests.RequestException as e:
        print(f"Error fetching {url}: {e}")
        return None

What does this code do?

  • The headers dictionary sets a User-Agent string that tells the web server who is making the request. This is good etiquette when scraping.
  • requests.get() sends an HTTP GET request to the URL. The timeout=10 parameter stops the request after 10 seconds if the server doesn't respond.
  • response.raise_for_status() checks if the server returned an error (like 404 Not Found or 500 Server Error) and raises an exception if so.
  • The except requests.RequestException block catches any network or HTTP error, prints a helpful message, and returns None so the rest of your script can handle the failure.
  • Save contact_extractor.py.

Why include a User-Agent header?

Many web servers block requests that don't identify themselves. By including a descriptive User-Agent, you let the server know your script is a simple learning tool. This reduces the chance of being blocked.

Test the function against a sample URL

Now that the function is saved, you can test it by importing it and calling it from the terminal.

  • In your terminal (with your virtual environment still active), run the following test command:
python -c "from contact_extractor import fetch_page; html = fetch_page('https://example.com'); print(f'Success! Got {len(html)} characters') if html else print('Request failed')"
  • You should see output similar to Success! Got 1256 characters (the exact number may vary).

Seeing an error instead of a character count?

  • If you see a ConnectionError, check that you have an internet connection and that no firewall is blocking Python.
  • If you see ModuleNotFoundError: No module named 'requests', make sure your virtual environment is activated. You should see its name in parentheses at the start of your terminal prompt.
  • If you see ImportError: cannot import name 'fetch_page', confirm you saved contact_extractor.py after adding the function.

help me debug my fetch_page function

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the full contact_extractor.py file up to this point:

import re
import csv
import sys
from urllib.parse import urljoin

import requests
from bs4 import BeautifulSoup


# Regex patterns
EMAIL_PATTERN = re.compile(
    r'[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}'
)

PHONE_PATTERN = re.compile(
    r'(\+?1[\s.\-]?)?'
    r'\(?\d{3}\)?[\s.\-]?'
    r'\d{3}[\s.\-]?'
    r'\d{4}'
)


def fetch_page(url):
    """Fetch a web page and return its HTML content."""
    headers = {
        'User-Agent': 'ContactExtractor/1.0 (Learning Project)'
    }
    try:
        response = requests.get(url, headers=headers, timeout=10)
        response.raise_for_status()
        return response.text
    except requests.RequestException as e:
        print(f"Error fetching {url}: {e}")
        return None

Your script can now reach out to any URL and grab its HTML. Next up, you'll write the regex-powered extraction functions that scan that HTML for email addresses and phone numbers.

Extract Emails and Phone Numbers with Regex

Your script can fetch a web page. Now you need to find the useful data buried in all that HTML.

Contact details like emails and phone numbers follow predictable text patterns. Regular expressions (regex) let you describe those patterns and scan entire pages for matches in just a few lines of code.

In this step, get ready to:
  • Write a function to extract email addresses from HTML.
  • Write a function to extract phone numbers from HTML.
  • Test both extraction functions with sample input.
Write the email extraction function

The EMAIL_PATTERN you defined earlier matches the standard email format. But raw HTML can produce false positives.

Strings like image@2x.png match the email pattern even though they are not real addresses. Your function needs to filter those out and return only unique results.

  • In contact_extractor.py, add the extract_emails function below the fetch_page function by copying this code:
def extract_emails(html):
    """Extract email addresses from HTML content."""
    emails = EMAIL_PATTERN.findall(html)
    # Filter out common false positives
    filtered = [
        e for e in emails
        if not e.endswith(('.png', '.jpg', '.gif', '.svg', '.css', '.js'))
    ]
    return list(set(filtered))

What does this code do?

  • EMAIL_PATTERN.findall(html) scans the entire HTML string and returns every substring that looks like an email address.
  • The list comprehension filters out matches that end in common file extensions. These are asset references (like icon@2x.png), not real emails.
  • list(set(filtered)) removes duplicates. A page might mention the same email address multiple times, but you only want it once in your output.
  • Save your file.
  • You won't see a change yet. The next function completes the extraction pair, and then you'll test both together.
Write the phone number extraction function

Phone numbers are trickier than emails. The PHONE_PATTERN uses a capturing group for the optional country code prefix.

When a regex has capturing groups, Python's findall returns only the captured group text instead of the full match. You'll use finditer instead, which gives you match objects containing the complete phone number.

  • Below the extract_emails function, add the extract_phones function by copying this code:
def extract_phones(html):
    """Extract phone numbers from HTML content."""
    phones = PHONE_PATTERN.findall(html)
    # Get the full match, not just the group
    full_matches = []
    for match in PHONE_PATTERN.finditer(html):
        phone = match.group().strip()
        if len(re.sub(r'\D', '', phone)) >= 10:
            full_matches.append(phone)
    return list(set(full_matches))

What does this code do?

  • PHONE_PATTERN.finditer(html) returns match objects for every phone-like string in the HTML. Each match object lets you call .group() to get the full matched text.
  • re.sub(r'\D', '', phone) strips all non-digit characters so you can count how many actual digits the match has. Valid US phone numbers have at least 10 digits.
  • The >= 10 check filters out partial matches that look phone-like but are too short to be real numbers (like three-digit area codes appearing alone).

Why does findall appear on line 3?

The phones = PHONE_PATTERN.findall(html) line demonstrates what happens when you use findall with a regex that has capturing groups. It returns only the group content (the optional country code prefix), not the full phone number.

The finditer loop that follows solves this by giving you the complete match. This is a common pattern when working with complex regex in Python.

  • Save contact_extractor.py.
  • Test both functions by running this command in your terminal:
python -c "from contact_extractor import extract_emails, extract_phones; print(extract_emails('<p>info@example.com or bad@file.png</p>')); print(extract_phones('<p>Call (555) 123-4567</p>'))"

You should see two lists printed. The first contains info@example.com (the .png address is filtered out). The second contains the phone number (555) 123-4567.

Seeing a ModuleNotFoundError or empty results?

  • Make sure your virtual environment is still active. You should see the environment name in your terminal prompt.
  • Check that you saved contact_extractor.py before running the test command. Unsaved changes are not visible to Python.
  • Verify your terminal is in the project folder (the same directory as contact_extractor.py). Python looks for the file relative to your current directory.

Why aren't my extraction functions returning results?

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the full contact_extractor.py so far:

import re
import csv
import sys
from urllib.parse import urljoin

import requests
from bs4 import BeautifulSoup


# Regex patterns
EMAIL_PATTERN = re.compile(
    r'[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}'
)

PHONE_PATTERN = re.compile(
    r'(\+?1[\s.\-]?)?'
    r'\(?\d{3}\)?[\s.\-]?'
    r'\d{3}[\s.\-]?'
    r'\d{4}'
)


def fetch_page(url):
    """Fetch a web page and return its HTML content."""
    headers = {
        'User-Agent': 'ContactExtractor/1.0 (Learning Project)'
    }
    try:
        response = requests.get(url, headers=headers, timeout=10)
        response.raise_for_status()
        return response.text
    except requests.RequestException as e:
        print(f"Error fetching {url}: {e}")
        return None


def extract_emails(html):
    """Extract email addresses from HTML content."""
    emails = EMAIL_PATTERN.findall(html)
    # Filter out common false positives
    filtered = [
        e for e in emails
        if not e.endswith(('.png', '.jpg', '.gif', '.svg', '.css', '.js'))
    ]
    return list(set(filtered))


def extract_phones(html):
    """Extract phone numbers from HTML content."""
    phones = PHONE_PATTERN.findall(html)
    # Get the full match, not just the group
    full_matches = []
    for match in PHONE_PATTERN.finditer(html):
        phone = match.group().strip()
        if len(re.sub(r'\D', '', phone)) >= 10:
            full_matches.append(phone)
    return list(set(full_matches))

Your extraction functions can pull emails and phone numbers from raw HTML. Next up, you'll use BeautifulSoup to find names and titles by reading the structure of the page itself.

Parse Names and Titles with BeautifulSoup

Your regex functions can find raw emails and phone numbers scattered across a page. But contact information rarely floats on its own.

On most team or contact pages, each person's details are grouped inside a card or section with a predictable HTML structure. In this step, you'll use BeautifulSoup to find those containers, then pull out names and titles from nearby heading and paragraph tags.

In this step, get ready to:
  • Parse HTML to find team/contact sections by class name patterns.
  • Extract names and titles from heading and paragraph tags inside each section.
  • Build a function that combines structured parsing with raw regex fallback.
Write the extract_contacts function

Most company team pages wrap each person in a container element (a div, section, or article) with a class name like "team-member" or "staff-card". Your function will search for these containers, then look inside each one for a name, title, email, and phone.

  • In contact_extractor.py, add the following function below your extract_phones function:
def extract_contacts(html):
    """Extract contact names and titles from HTML using BeautifulSoup."""
    soup = BeautifulSoup(html, 'html.parser')
    contacts = []

    # Look for common team/contact sections
    team_sections = soup.find_all(
        ['div', 'section', 'article'],
        class_=re.compile(
            r'team|staff|people|member|contact|employee|card',
            re.IGNORECASE
        )
    )

    for section in team_sections:
        contact = {}

        # Look for name in heading tags
        name_tag = section.find(['h2', 'h3', 'h4', 'h5', 'strong'])
        if name_tag:
            contact['name'] = name_tag.get_text(strip=True)

        # Look for title/role in paragraph or span
        title_tag = section.find(
            ['p', 'span'],
            class_=re.compile(
                r'title|role|position|job|designation',
                re.IGNORECASE
            )
        )
        if title_tag:
            contact['title'] = title_tag.get_text(strip=True)

        # Look for email in this section
        section_html = str(section)
        emails = extract_emails(section_html)
        if emails:
            contact['email'] = emails[0]

        # Look for phone in this section
        phones = extract_phones(section_html)
        if phones:
            contact['phone'] = phones[0]

        if contact.get('name') or contact.get('email'):
            contacts.append(contact)

    return contacts

What does this code do?

This function uses BeautifulSoup's find_all to locate container elements whose class names match common team page patterns like "team", "staff", "member", or "card".

  • soup.find_all searches for div, section, and article tags with matching class names using a regex pattern.
  • Inside each section, it grabs the first heading tag (h2 through h5, or strong) as the person's name.
  • It looks for a p or span tag with a class name like "title" or "role" to find job titles.
  • It reuses your extract_emails and extract_phones functions on the section's HTML to find associated contact details.
  • A contact is only added to the list if it has at least a name or an email.
  • Save contact_extractor.py.

You won't see a change yet. The next function will use this to build a full contact list.

Seeing a syntax error on save?

  • Check that the function is placed after extract_phones and is at the top indentation level (not nested inside another function).
  • Make sure the regex string inside class_=re.compile(...) uses a raw string prefix (r'...').
  • Verify all parentheses are balanced. The find_all call and the nested re.compile each have their own opening and closing parentheses.

Why is my extract_contacts function causing a syntax error?

Build the complete contact list

The extract_contacts function works well on pages with structured team cards. But many pages have emails or phone numbers scattered outside those containers. You need a fallback strategy that catches contacts even when the HTML is not neatly organized.

  • Add this function below extract_contacts in contact_extractor.py:
def build_contact_list(html):
    """Build a complete contact list from a page."""
    # Get structured contacts from HTML patterns
    contacts = extract_contacts(html)

    # Get all emails and phones from the full page
    all_emails = extract_emails(html)
    all_phones = extract_phones(html)

    # If no structured contacts found, create entries from raw extractions
    if not contacts and all_emails:
        for email in all_emails:
            contacts.append({
                'name': '',
                'title': '',
                'email': email,
                'phone': ''
            })

    # Fill in missing fields with defaults
    for contact in contacts:
        contact.setdefault('name', '')
        contact.setdefault('title', '')
        contact.setdefault('email', '')
        contact.setdefault('phone', '')

    # Add any unmatched phones to contacts without phones
    unmatched_phones = [p for p in all_phones if not any(
        c.get('phone') == p for c in contacts
    )]
    for i, contact in enumerate(contacts):
        if not contact['phone'] and i < len(unmatched_phones):
            contact['phone'] = unmatched_phones[i]

    return contacts

What does this code do?

This function is the main entry point for contact extraction. It layers multiple strategies to maximize results.

  • It first tries the structured approach via extract_contacts (finding cards with class names).
  • If no structured contacts are found, it falls back to creating entries from any raw emails found on the page.
  • contact.setdefault fills in missing dictionary keys with empty strings so downstream code never hits a KeyError.
  • Finally, it distributes unmatched phone numbers to contacts that don't already have one.
  • Save contact_extractor.py.

You won't see output yet. The next step will wire these functions into a main block that runs the full extraction and writes results to a CSV.

Seeing an indentation error?

  • Make sure build_contact_list is at the same indentation level as your other functions (not nested inside extract_contacts).
  • The list comprehension for unmatched_phones spans two lines. Check that the second line is indented further than the first.
  • Verify there is a blank line between the end of extract_contacts and the start of build_contact_list.

Help me fix my build_contact_list function.

Why structural parsing instead of NLP?

You might wonder why we look for HTML class names instead of using a natural language processing library to identify names. NLP tools like spaCy add hundreds of megabytes of dependencies and still struggle with short text fragments.

Team pages already have structure baked into their HTML. A heading inside a "team-member" div is almost certainly a name. Leveraging that existing structure is faster, lighter, and more reliable for this use case.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the full contact_extractor.py file up to this point:

import re
import csv
import sys
from urllib.parse import urljoin

import requests
from bs4 import BeautifulSoup


# Regex patterns
EMAIL_PATTERN = re.compile(
    r'[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}'
)

PHONE_PATTERN = re.compile(
    r'(\+?1[\s.\-]?)?'
    r'\(?\d{3}\)?[\s.\-]?'
    r'\d{3}[\s.\-]?'
    r'\d{4}'
)


def fetch_page(url):
    """Fetch a web page and return its HTML content."""
    headers = {
        'User-Agent': 'ContactExtractor/1.0 (Learning Project)'
    }
    try:
        response = requests.get(url, headers=headers, timeout=10)
        response.raise_for_status()
        return response.text
    except requests.RequestException as e:
        print(f"Error fetching {url}: {e}")
        return None


def extract_emails(html):
    """Extract email addresses from HTML content."""
    emails = EMAIL_PATTERN.findall(html)
    # Filter out common false positives
    filtered = [
        e for e in emails
        if not e.endswith(('.png', '.jpg', '.gif', '.svg', '.css', '.js'))
    ]
    return list(set(filtered))


def extract_phones(html):
    """Extract phone numbers from HTML content."""
    phones = PHONE_PATTERN.findall(html)
    # Get the full match, not just the group
    full_matches = []
    for match in PHONE_PATTERN.finditer(html):
        phone = match.group().strip()
        if len(re.sub(r'\D', '', phone)) >= 10:
            full_matches.append(phone)
    return list(set(full_matches))


def extract_contacts(html):
    """Extract contact names and titles from HTML using BeautifulSoup."""
    soup = BeautifulSoup(html, 'html.parser')
    contacts = []

    # Look for common team/contact sections
    team_sections = soup.find_all(
        ['div', 'section', 'article'],
        class_=re.compile(
            r'team|staff|people|member|contact|employee|card',
            re.IGNORECASE
        )
    )

    for section in team_sections:
        contact = {}

        # Look for name in heading tags
        name_tag = section.find(['h2', 'h3', 'h4', 'h5', 'strong'])
        if name_tag:
            contact['name'] = name_tag.get_text(strip=True)

        # Look for title/role in paragraph or span
        title_tag = section.find(
            ['p', 'span'],
            class_=re.compile(
                r'title|role|position|job|designation',
                re.IGNORECASE
            )
        )
        if title_tag:
            contact['title'] = title_tag.get_text(strip=True)

        # Look for email in this section
        section_html = str(section)
        emails = extract_emails(section_html)
        if emails:
            contact['email'] = emails[0]

        # Look for phone in this section
        phones = extract_phones(section_html)
        if phones:
            contact['phone'] = phones[0]

        if contact.get('name') or contact.get('email'):
            contacts.append(contact)

    return contacts


def build_contact_list(html):
    """Build a complete contact list from a page."""
    # Get structured contacts from HTML patterns
    contacts = extract_contacts(html)

    # Get all emails and phones from the full page
    all_emails = extract_emails(html)
    all_phones = extract_phones(html)

    # If no structured contacts found, create entries from raw extractions
    if not contacts and all_emails:
        for email in all_emails:
            contacts.append({
                'name': '',
                'title': '',
                'email': email,
                'phone': ''
            })

    # Fill in missing fields with defaults
    for contact in contacts:
        contact.setdefault('name', '')
        contact.setdefault('title', '')
        contact.setdefault('email', '')
        contact.setdefault('phone', '')

    # Add any unmatched phones to contacts without phones
    unmatched_phones = [p for p in all_phones if not any(
        c.get('phone') == p for c in contacts
    )]
    for i, contact in enumerate(contacts):
        if not contact['phone'] and i < len(unmatched_phones):
            contact['phone'] = unmatched_phones[i]

    return contacts

Your extraction engine is complete. Next up, you'll wire everything together into a script that runs from the command line and exports the results to a clean CSV file.

Export Results to CSV

Your extractor can now pull emails, phone numbers, names, and titles from HTML. The missing piece is tying all of that together and saving the results somewhere useful.

In this step, you will write a function that exports contacts to a CSV file, then build the main() function that orchestrates everything from URL input to file output.

In this step, get ready to:
  • Write a function that saves contact data to a CSV file with structured columns.
  • Build a main function that reads a URL, extracts contacts, and prints a summary.
  • Run the script against a real web page to confirm everything works end to end.
Write the CSV export function

Python's built-in csv module includes a DictWriter class that writes dictionaries to CSV rows. Each contact dictionary maps directly to a row with columns for name, title, email, and phone.

  • In contact_extractor.py, scroll down to the bottom of the file, below the build_contact_list function.
  • Add the following function:
def save_to_csv(contacts, filename='contacts.csv'):
    """Save contacts to a CSV file."""
    if not contacts:
        print("No contacts found to save.")
        return

    fieldnames = ['name', 'title', 'email', 'phone']

    with open(filename, 'w', newline='', encoding='utf-8') as csvfile:
        writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
        writer.writeheader()
        for contact in contacts:
            writer.writerow({
                'name': contact.get('name', ''),
                'title': contact.get('title', ''),
                'email': contact.get('email', ''),
                'phone': contact.get('phone', ''),
            })

    print(f"Saved {len(contacts)} contacts to {filename}")

What does this code do?

  • The function takes a list of contact dictionaries and an optional filename (defaulting to contacts.csv).
  • csv.DictWriter handles mapping dictionary keys to CSV columns. You define the column order with fieldnames and it writes matching values into each column.
  • The newline='' parameter prevents blank rows on Windows. The encoding='utf-8' ensures special characters in names are preserved.
  • Save contact_extractor.py.

You won't see a change yet. The next chunk builds the main function that actually calls this.

Build the main function

The main() function is the entry point that ties all your extraction logic together. It reads a URL from the command line, fetches the page, builds the contact list, prints a summary, and exports to CSV.

  • Below the save_to_csv function you just wrote, add the following code at the bottom of contact_extractor.py:
def main():
    if len(sys.argv) < 2:
        print("Usage: python contact_extractor.py <url>")
        print("Example: python contact_extractor.py https://example.com/team")
        sys.exit(1)

    url = sys.argv[1]
    print(f"Fetching: {url}")

    html = fetch_page(url)
    if not html:
        sys.exit(1)

    print(f"Page fetched successfully ({len(html)} characters)")

    contacts = build_contact_list(html)

    print(f"\nFound {len(contacts)} contact(s):")
    for contact in contacts:
        parts = []
        if contact['name']:
            parts.append(contact['name'])
        if contact['title']:
            parts.append(f"({contact['title']})")
        if contact['email']:
            parts.append(contact['email'])
        if contact['phone']:
            parts.append(contact['phone'])
        print(f"  - {' | '.join(parts)}")

    save_to_csv(contacts)


if __name__ == '__main__':
    main()

What does this code do?

  • sys.argv reads command-line arguments. The script expects the learner to pass a URL when running it. If no URL is provided, it prints usage instructions and exits.
  • The summary loop builds a readable line for each contact, joining non-empty fields with a pipe separator.
  • if __name__ == '__main__': ensures main() only runs when you execute the script directly, not when it is imported as a module.
  • Save contact_extractor.py.

Seeing a red underline on sys.argv?

Make sure you have import sys at the top of your file. This import was added in an earlier step. If it is missing, add it to the import block at the top.

Help me figure out why sys is not recognized in my Python script.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

⨯ I'd like to double check the full code

Here is the complete contact_extractor.py file at this point:

import re
import csv
import sys
from urllib.parse import urljoin

import requests
from bs4 import BeautifulSoup


# Regex patterns
EMAIL_PATTERN = re.compile(
    r'[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}'
)

PHONE_PATTERN = re.compile(
    r'(\+?1[\s.\-]?)?'
    r'\(?\d{3}\)?[\s.\-]?'
    r'\d{3}[\s.\-]?'
    r'\d{4}'
)


def fetch_page(url):
    """Fetch a web page and return its HTML content."""
    headers = {
        'User-Agent': 'ContactExtractor/1.0 (Learning Project)'
    }
    try:
        response = requests.get(url, headers=headers, timeout=10)
        response.raise_for_status()
        return response.text
    except requests.RequestException as e:
        print(f"Error fetching {url}: {e}")
        return None


def extract_emails(html):
    """Extract email addresses from HTML content."""
    emails = EMAIL_PATTERN.findall(html)
    # Filter out common false positives
    filtered = [
        e for e in emails
        if not e.endswith(('.png', '.jpg', '.gif', '.svg', '.css', '.js'))
    ]
    return list(set(filtered))


def extract_phones(html):
    """Extract phone numbers from HTML content."""
    phones = PHONE_PATTERN.findall(html)
    # Get the full match, not just the group
    full_matches = []
    for match in PHONE_PATTERN.finditer(html):
        phone = match.group().strip()
        if len(re.sub(r'\D', '', phone)) >= 10:
            full_matches.append(phone)
    return list(set(full_matches))


def extract_contacts(html):
    """Extract contact names and titles from HTML using BeautifulSoup."""
    soup = BeautifulSoup(html, 'html.parser')
    contacts = []

    # Look for common team/contact sections
    team_sections = soup.find_all(
        ['div', 'section', 'article'],
        class_=re.compile(
            r'team|staff|people|member|contact|employee|card',
            re.IGNORECASE
        )
    )

    for section in team_sections:
        contact = {}

        # Look for name in heading tags
        name_tag = section.find(['h2', 'h3', 'h4', 'h5', 'strong'])
        if name_tag:
            contact['name'] = name_tag.get_text(strip=True)

        # Look for title/role in paragraph or span
        title_tag = section.find(
            ['p', 'span'],
            class_=re.compile(
                r'title|role|position|job|designation',
                re.IGNORECASE
            )
        )
        if title_tag:
            contact['title'] = title_tag.get_text(strip=True)

        # Look for email in this section
        section_html = str(section)
        emails = extract_emails(section_html)
        if emails:
            contact['email'] = emails[0]

        # Look for phone in this section
        phones = extract_phones(section_html)
        if phones:
            contact['phone'] = phones[0]

        if contact.get('name') or contact.get('email'):
            contacts.append(contact)

    return contacts


def build_contact_list(html):
    """Build a complete contact list from a page."""
    # Get structured contacts from HTML patterns
    contacts = extract_contacts(html)

    # Get all emails and phones from the full page
    all_emails = extract_emails(html)
    all_phones = extract_phones(html)

    # If no structured contacts found, create entries from raw extractions
    if not contacts and all_emails:
        for email in all_emails:
            contacts.append({
                'name': '',
                'title': '',
                'email': email,
                'phone': ''
            })

    # Fill in missing fields with defaults
    for contact in contacts:
        contact.setdefault('name', '')
        contact.setdefault('title', '')
        contact.setdefault('email', '')
        contact.setdefault('phone', '')

    # Add any unmatched phones to contacts without phones
    unmatched_phones = [p for p in all_phones if not any(
        c.get('phone') == p for c in contacts
    )]
    for i, contact in enumerate(contacts):
        if not contact['phone'] and i < len(unmatched_phones):
            contact['phone'] = unmatched_phones[i]

    return contacts


def save_to_csv(contacts, filename='contacts.csv'):
    """Save contacts to a CSV file."""
    if not contacts:
        print("No contacts found to save.")
        return

    fieldnames = ['name', 'title', 'email', 'phone']

    with open(filename, 'w', newline='', encoding='utf-8') as csvfile:
        writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
        writer.writeheader()
        for contact in contacts:
            writer.writerow({
                'name': contact.get('name', ''),
                'title': contact.get('title', ''),
                'email': contact.get('email', ''),
                'phone': contact.get('phone', ''),
            })

    print(f"Saved {len(contacts)} contacts to {filename}")


def main():
    if len(sys.argv) < 2:
        print("Usage: python contact_extractor.py <url>")
        print("Example: python contact_extractor.py https://example.com/team")
        sys.exit(1)

    url = sys.argv[1]
    print(f"Fetching: {url}")

    html = fetch_page(url)
    if not html:
        sys.exit(1)

    print(f"Page fetched successfully ({len(html)} characters)")

    contacts = build_contact_list(html)

    print(f"\nFound {len(contacts)} contact(s):")
    for contact in contacts:
        parts = []
        if contact['name']:
            parts.append(contact['name'])
        if contact['title']:
            parts.append(f"({contact['title']})")
        if contact['email']:
            parts.append(contact['email'])
        if contact['phone']:
            parts.append(contact['phone'])
        print(f"  - {' | '.join(parts)}")

    save_to_csv(contacts)


if __name__ == '__main__':
    main()
Run the extractor against a real page

Your script is complete. Time to run it against a real web page and confirm that it produces a CSV file.

  • In your terminal (with your virtual environment still active), run the script by passing a URL as an argument:
python contact_extractor.py [[TARGET_URL="URL of a website with a team or contact page"]]

Choose any public team page

Pick any company or organization website that has a "Team", "About Us", or "Contact" page with staff listings. The more structured the page (names in headings, emails visible), the more contacts your script will find.

  • Check your terminal output. You should see a message confirming the page was fetched, a count of contacts found, and a list of extracted contact details.
  • Confirm the CSV file was created by running:
ls contacts.csv
  • You should see contacts.csv listed in the output.
  • View the contents of your CSV to check the data by running:
cat contacts.csv
  • You should see a header row (name,title,email,phone) followed by one row per contact.

What if the script found 0 contacts?

Not every website structures its HTML in a way that the extractor can parse. If you see 0 contacts, try a different page with a visible team listing. The script works best on pages that use class names like "team", "staff", or "member" in their HTML.

Seeing an error when running the script?

  • Make sure your virtual environment is active. You should see (venv) at the beginning of your terminal prompt.
  • Check that you included a URL after the script name. The script requires exactly one argument.
  • If you see a connection error, check that the URL starts with https:// and points to a real, publicly accessible website.

Help me debug why my contact_extractor.py script is failing when I run it.

Why CSV for output?

CSV is a universal format that opens in Excel, Google Sheets, and any data tool. It is also easy to read programmatically if you want to feed this data into another script or pipeline later.

Your contact extractor is fully functional. It fetches a page, scans for names, titles, emails, and phone numbers, then saves everything to a clean CSV. Next up, the Secret Mission will challenge you to crawl multiple pages from the same domain to find even more contacts.

Secret mission

Multi-Page Crawling

Your extractor works great on a single page. But what about companies that spread their team across multiple pages? Extend your tool to automatically find and crawl linked pages like /about, /team, and /contact from the same domain, then combine all results into a single CSV.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • Project folder on your Desktop (containing contact_extractor.py, the virtual environment, and contacts.csv).
  • Python 3.10+ installed on your system.
  • VS Code installed on your system.

Keep everything running

No action needed. Choose this if you're still actively building or want to experiment with different URLs and regex patterns.

  • Your project folder stays on your Desktop with all files intact.
  • Your virtual environment stays ready to activate whenever you want to run the script again.
  • Python and VS Code remain installed for future projects.

Pause - I'll come back to this later

Deactivate your virtual environment to free up the terminal session. Your files stay on disk and you can reactivate later.

  • Deactivate the virtual environment by running:
deactivate
  • Close VS Code.
  • When you come back, open your project folder in VS Code and reactivate the virtual environment with source venv/bin/activate (macOS/Linux) or venv\Scripts\activate (Windows).

Delete - I don't want to use this again

Remove all project files and start fresh. Python and VS Code remain installed for other work.

  • Deactivate the virtual environment first by running:
deactivate
  • Delete the project folder from your Desktop by running:
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your-project-folder-name"]]

On Windows, use this command instead:

cd ~\Desktop
rmdir /s /q [[PROJECT_FOLDER="your-project-folder-name"]]
  • Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows). You should no longer see your project folder in the listing.

If you want to uninstall Python or VS Code entirely, use your system's standard app removal process.

  • On macOS, open Finder, go to Applications, and drag the app to the Trash.
  • On Windows, open Settings, select Apps, then Installed apps. Find the app and click Uninstall.

Nice Work!

Nice Work!

Nice work! You just built a contact extraction tool that fetches web pages, scans their HTML, and outputs structured contact data to a CSV file.

You've learned how to:

  • Fetch web pages using the requests library with proper headers and error handling.
  • Write regex patterns to extract emails and phone numbers from raw HTML.
  • Use BeautifulSoup to parse HTML structure and identify names and titles.
  • Export structured contact data to a CSV file with clean column formatting.
  • Extended the tool to crawl multiple pages and combine deduplicated results.

Ready to quiz yourself?