Build a Basic Web Scraper
Learn to build a basic web scraper with requests and BeautifulSoup. This hands-on Python tutorial covers step-by-step scraping, troubleshooting, and next steps for your learning path.
Focus: build a basic web scraper with requests and beautifulsoup
Building a web scraper from scratch can feel like sneaking into someone else's house — you need the right tools and a clear understanding of what's allowed. Without a proper approach, you'll end up with broken HTML, blocked requests, or worse, legal headaches. This lesson walks you through building a basic web scraper with requests and BeautifulSoup, two powerhouse Python libraries that make extracting data from websites both safe and effective.
The problem this lesson solves
Manual data collection is slow and error-prone. Whether you're tracking product prices, aggregating news headlines, or gathering research data, copying and pasting from dozens of web pages wastes hours. Web scraping automates this, but many developers struggle with the first step: getting the HTML and parsing it reliably.
The core challenge is that websites aren't databases — they display data in messy, nested HTML structures. You need a way to:
- Fetch the raw HTML from a URL (without getting blocked)
- Parse that HTML into a navigable tree
- Extract exactly the pieces you need (headlines, prices, links, etc.)
- Handle errors when the page structure changes or a request fails
Using requests for the fetch and BeautifulSoup for parsing gives you a clean, Pythonic solution that works for 90% of scraping needs.
Pro tip: Always check a website's
robots.txtand terms of service before scraping. Respectingrobots.txtis both ethical and keeps your IP from being banned.
Core concept / mental model
Think of a web scraper as a three-layer sandwich:
- Layer 1: The Fetcher (
requests) — This makes an HTTP GET request to the server and brings back the raw HTML response. It's like dialing a phone number and getting the voicemail recording. - Layer 2: The Parser (
BeautifulSoup) — This turns the raw text into a structured tree of tags, attributes, and text nodes. Think of it as converting a voice recording into a written transcript with timestamps. - Layer 3: The Extractor (CSS selectors or
.find()) — You query the parsed tree for specific elements — all<h2>tags, all links with a certain class, or the third<div>inside a<section>. This is your highlight pen on the transcript.
The magic happens when you chain these layers: requests → BeautifulSoup → .select() or .find_all().
Definitions you need
| Term | Meaning |
|---|---|
| HTTP GET request | A request to fetch data from a server (not modify it) |
| Response object | What requests.get() returns — contains .text, .status_code, .headers |
| BeautifulSoup object | The parsed representation of HTML — a tree you can traverse |
| CSS selector | A pattern like div.article h2 that matches elements based on class, tag, hierarchy |
find_all() |
Returns a list of all matching tags from the soup object |
How it works step by step
Let's build the scraper piece by piece. The flow is always:
- Install dependencies (one-time setup)
- Fetch the page and check the response status
- Parse the HTML into a BeautifulSoup object
- Locate target elements using tags, classes, or CSS selectors
- Extract data — text, links, or attributes
- Handle errors gracefully (timeouts, missing elements, status codes)
Step 1: Install requests and beautifulsoup4
pip install requests beautifulsoup4
Note: The package name for installation is beautifulsoup4, but you import it as bs4.
Step 2: Fetch a webpage
import requests
url = "https://example.com/news"
response = requests.get(url)
# Always check the response status
if response.status_code == 200:
html_content = response.text
print("Page fetched successfully")
else:
print(f"Failed to fetch page: {response.status_code}")
Step 3: Parse with BeautifulSoup
from bs4 import BeautifulSoup
soup = BeautifulSoup(html_content, 'html.parser')
The second argument 'html.parser' tells BeautifulSoup to use Python's built-in HTML parser. You can also use 'lxml' after installing lxml (faster but extra dependency).
Step 4: Extract elements using find_all()
# Find all <h2> tags (often headings)
headings = soup.find_all('h2')
for heading in headings:
print(heading.text.strip())
heading.text returns the visible text inside the tag. .strip() removes extra whitespace.
Step 5: Use CSS selectors for precision
When pages use specific classes or IDs, CSS selectors are powerful:
# Find all links inside elements with class "article-card"
articles = soup.select('.article-card a')
for link in articles:
href = link.get('href')
title = link.text.strip()
print(f"{title}: {href}")
The .select() method accepts any CSS selector — learn the basics and you can target almost anything.
Hands-on walkthrough
Let's scrape a public, friendly site — Books to Scrape (http://books.toscrape.com), a demo site built for practicing scraping.
Complete scraper: Extract book titles and prices
import requests
from bs4 import BeautifulSoup
url = "http://books.toscrape.com/catalogue/page-1.html"
response = requests.get(url)
if response.status_code != 200:
print(f"Failed to fetch page: {response.status_code}")
exit()
soup = BeautifulSoup(response.text, 'html.parser')
# Each book is inside an <article> with class "product_pod"
books = soup.find_all('article', class_='product_pod')
for book in books:
# Extract title from <h3><a> tag
title_tag = book.find('h3').find('a')
title = title_tag.get('title', title_tag.text.strip()) if title_tag else "No title"
# Extract price from <p> with class "price_color"
price_tag = book.find('p', class_='price_color')
price = price_tag.text.strip() if price_tag else "No price"
print(f"{title}: {price}")
Expected output (truncated):
A Light in the Attic: £51.77
Tipping the Velvet: £53.74
Soumission: £50.10
...
Scraper with error handling and headers
Many modern websites block scrapers with no user-agent. Always send a realistic user-agent header:
import requests
from bs4 import BeautifulSoup
import time
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
}
try:
response = requests.get("http://books.toscrape.com/", headers=headers, timeout=10)
response.raise_for_status() # Raises HTTPError for bad status codes
except requests.exceptions.RequestException as e:
print(f"Request failed: {e}")
exit()
soup = BeautifulSoup(response.text, 'html.parser')
# Sleep between requests to be polite
time.sleep(1)
# Extract categories from sidebar
categories = soup.select('.side_categories ul li a')
for cat in categories:
cat_text = cat.text.strip()
# Skip empty or "Books" category
if cat_text and cat_text.lower() != "books":
print(f"Category: {cat_text}")
Expected output (partial):
Category: Travel
Category: Mystery
Category: Historical Fiction
Category: Sequential Art
...
Critical rule: Always respect
robots.txt. For http://books.toscrape.com/robots.txt, you'll see it allows all — perfect for practice.
Compare options / when to choose what
Parser choices for BeautifulSoup
| Parser | Pros | Cons |
|---|---|---|
'html.parser' |
Built-in, no extra install | Slower with deeply nested HTML |
'lxml' |
Fast, lenient with broken HTML | Requires pip install lxml |
'html5lib' |
Most accurate (follows HTML5 spec) | Very slow, extra install |
Recommendation: Start with 'html.parser'. Switch to 'lxml' when scraping large sites or when the HTML is malformed.
find_all() vs .select()
| Method | Use when | Example |
|---|---|---|
find_all('tag') |
You know the tag name | soup.find_all('a') |
find_all('tag', class_='name') |
Tag + class exact match | soup.find_all('div', class_='content') |
.select('css-selector') |
Complex hierarchy or attribute patterns | soup.select('div.content > p a') |
.select() is more powerful but slower for simple cases. Use find_all for clarity, .select() for tricky DOM structures.
Requests vs. other HTTP libraries
| Library | When to choose |
|---|---|
requests |
Simple GET/POST, basic scraping, standard auth |
httpx |
Async support, HTTP/2, modern API |
urllib |
Built-in, no pip install (but less ergonomic) |
Stick with requests for this stage — it's the simplest and most widely used.
Troubleshooting & edge cases
1. HTTP 403 Forbidden
Cause: The server sees your default requests user-agent and blocks it.
Fix: Add a realistic User-Agent header (as shown in the hands-on section).
2. Empty results from find_all() or .select()
Cause: The element you're looking for is loaded dynamically via JavaScript (e.g., React, Vue). requests only fetches the initial HTML, not JS-generated content.
Fix: Check the page source. If the data isn't there, you need a headless browser like Selenium or Playwright (covered in a later lesson).
3. Stale data or broken scraping after a week
Cause: The website updated its HTML structure — class names changed, elements were reorganized. Fix: Build in defensive checks:
if not books:
print("No books found — structure may have changed")
# Log the raw HTML for inspection
with open('debug.html', 'w') as f:
f.write(response.text)
4. SSL certificate errors
response = requests.get(url, verify=False) # Not recommended in production
Better approach: install certificates or use verify='/path/to/cert.pem'.
5. Rate limiting
Fix: Add time.sleep(1) between requests or use requests with a retry adapter.
from requests.adapters import HTTPAdapter
from requests.packages.urllib3.util.retry import Retry
session = requests.Session()
retries = Retry(total=3, backoff_factor=0.5)
session.mount('https://', HTTPAdapter(max_retries=retries))
What you learned & what's next
You now know how to build a basic web scraper with requests and BeautifulSoup:
- Fetching HTML with proper headers and error handling
- Parsing HTML into a navigable BeautifulSoup tree
- Extracting data using
find_all()and.select() - Choosing the right parser and method for the task
- Handling common issues like 403 errors, dynamic content, and stale structures
You've completed Learning Objective 1 (explain the core idea) and Learning Objective 2 (practical exercise).
What's next: The next lesson covers scraping multiple pages and handling pagination — turning your single-page scraper into a data collection machine. You'll learn to follow "Next" links and build a loop that respects rate limits.
Practice recap
Try it yourself: Modify the Books to Scrape scraper to extract the star rating (from the <p> with class star-rating) alongside the title and price. The rating class is One, Two, Three, Four, or Five. Print the output as a table. If you get stuck, inspect the HTML in your browser's DevTools — the rating is a <p> tag with two classes: star-rating and the rating name.
Common mistakes
- Forgetting to add a User-Agent header — websites block requests with the default Python user-agent, causing 403 errors.
- Using
.texton a BeautifulSoup object instead of individual tags —.textreturns all text in the tree, not just the element you want. - Assuming HTML structure won't change — scraping breaks silently when class names or tag hierarchies change; always add checks for empty results.
- Scraping without checking
robots.txt— you might get banned or violate the website's terms, especially on commercial sites.
Variations
- Use
lxmlparser for faster parsing of large HTML documents (installpip install lxmland pass'lxml'to BeautifulSoup constructor). - Combine with
requests.Session()to persist cookies across requests, useful for scraping pages behind a login. - Use CSS pseudo-classes like
:first-childor:nth-of-type(2)in.select()for more precise element targeting.
Real-world use cases
- Extracting product prices and stock status from an e-commerce store's catalog page for competitive analysis.
- Aggregating headlines and article summaries from multiple news sites into a single daily briefing feed.
- Gathering job listings (title, company, location) from a job board to build a filtered search tool.
Key takeaways
- Web scraping is a three-step pipeline: fetch with requests, parse with BeautifulSoup, extract with find_all() or select().
- Always set a custom User-Agent header and check the response status code to avoid blocks and silent failures.
- Use
find_all()for simple tag+class searches; use.select()for complex CSS selector patterns. - Respect robots.txt and add delays between requests to avoid being banned or overwhelming servers.
- Dynamic JavaScript content cannot be scraped with requests alone — you need a headless browser for SPAs.
- Build defensive checks (empty results, debug HTML dumps) to catch when a website's structure changes.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.