Lesson 15 of 16 · Level 4: Expert

Web Scraping with Python: requests and BeautifulSoup Step by Step

  • ⏱ 13 min read
  • ❓ 4 review questions
  • ✍️ Exercise with solution
In this lesson
  1. Before you start: the rules
  2. Install and download the page
  3. Finding the data: inspecting the HTML
  4. A function that extracts every book on a page
  5. Crawling several pages responsibly
  6. When the page is built with JavaScript
  7. Real case: auditing your own store's online catalog
  8. Common scraping errors
  9. Summary
What you'll learn
  • Know when web scraping is legal and ethical, and when it isn't.
  • Download a page with requests and extract data with BeautifulSoup and CSS selectors.
  • Walk through several pages responsibly and save the data to CSV.
  • Know what to do with pages that load their data with JavaScript.
  • Audit your own business's online catalog and handle missing elements without crashing.

Lots of useful data doesn't come in a file or through an API: catalog prices, job listings, schedules, public directories. Web scraping is the technique of extracting that information from web pages with a program. It's a valued skill in data analysis, market research and automation, and with what you already know about requests (lesson 12), you're one step away.

1,000books across 50 pages on the practice site, built so people can learn scraping with permission
1 sminimum pause between pages: basic courtesy toward the server
robots.txtis the first file you check before crawling any site

Before you start: the rules

A page being public doesn't mean you can do anything you want with its data. Before extracting data from a site, go through this list:

To practice, we'll use books.toscrape.com, a fictional bookstore built specifically for learning to scrape: 1,000 books spread over 50 pages, with explicit permission to practice. (It's a UK-style demo store, which is why prices are in pounds.)

Install and download the page

pip install requests beautifulsoup4 pandas
import requests
from bs4 import BeautifulSoup

URL = "https://books.toscrape.com/"
headers = {"User-Agent": "python-course-scraper/1.0 (educational practice)"}

resp = requests.get(URL, headers=headers, timeout=10)
resp.raise_for_status()                       # error if the response isn't 200

soup = BeautifulSoup(resp.content, "html.parser")
print(soup.title.get_text(strip=True))
All products | Books to Scrape - Sandbox

We pass resp.content (the raw bytes) rather than resp.text so BeautifulSoup can detect the page's encoding itself. This site doesn't declare its encoding in the response headers, so with resp.text the £ sign shows up as "£".

Finding the data: inspecting the HTML

In your browser, right-click a book title and choose Inspect. You'll see that each book has this structure (simplified):

<article class="product_pod">
  <p class="star-rating Three"></p>
  <h3><a href="catalogue/a-light-in-the-attic_1000/index.html"
         title="A Light in the Attic">A Light in the ...</a></h3>
  <div class="product_price">
    <p class="price_color">£51.77</p>
    <p class="instock availability">In stock</p>
  </div>
</article>
HTML tree of one book The article.product_pod element contains p.star-rating, h3 and div.product_price. Inside h3 is the link a with the title; inside div.product_price is p.price_color with the price. article.product_pod p.star-rating h3 div.product_price a [title] p.price_color → "Three" → title → £51.77
  • Container 1 per booksoup.select("article.product_pod") returns all 20 on the page.
  • Data you extract text or attributeThe title comes from the link's title attribute; the price from the text of p.price_color.
  • Data hidden in a class starsThe rating isn't text: it's the second class of p.star-rating.
A CSS selector describes a path through this tree: h3 a means "a link inside an h3."

With that structure you write CSS selectors, the same ones web designers use to style pages:

SelectorSelects
article.product_podEach article tag with the class product_pod (one book)
p.price_colorThe price paragraph
h3 aThe link inside the h3
li.next aThe link to the next page

In BeautifulSoup, soup.select(selector) returns a list of every matching element, and soup.select_one(selector) returns only the first.

books = soup.select("article.product_pod")
print(len(books))

first = books[0]
print(first.h3.a["title"])                                  # the title attribute
print(first.select_one("p.price_color").get_text(strip=True))
20
A Light in the Attic
£51.77

1. Each book's full title is in the link's title attribute, while the visible text is cut off ("A Light in the ..."). How do you get the full title?

  • link.get_text()
  • link.title()
  • link["title"]

You read a tag's attributes like a dictionary. get_text() returns only the visible text, which is truncated here.

A function that extracts every book on a page

from urllib.parse import urljoin

STARS = {"One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5}

def extract_books(soup, page_url):
    books = []
    for art in soup.select("article.product_pod"):
        link = art.h3.a
        price_txt = art.select_one("p.price_color").get_text(strip=True)
        classes = art.select_one("p.star-rating")["class"]     # ["star-rating", "Three"]
        books.append({
            "title": link["title"],
            "price": float(price_txt.replace("£", "")),
            "stars": STARS.get(classes[1], 0),
            "in_stock": "In stock" in art.select_one("p.availability").get_text(),
            "url": urljoin(page_url, link["href"]),
        })
    return books

Two professional touches: we convert the price to a number (so we can calculate with it) and use urljoin to turn relative links (catalogue/…) into full addresses.

Crawling several pages responsibly

import time
import pandas as pd

def scrape(start_url, max_pages=3, delay=1.0):
    all_books, url, page = [], start_url, 0
    with requests.Session() as session:
        session.headers.update(headers)
        while url and page < max_pages:
            resp = session.get(url, timeout=10)
            resp.raise_for_status()
            soup = BeautifulSoup(resp.content, "html.parser")
            all_books.extend(extract_books(soup, url))
            page += 1
            next_link = soup.select_one("li.next a")
            url = urljoin(url, next_link["href"]) if next_link else None
            time.sleep(delay)                          # don't overload the server
    return all_books

books = scrape("https://books.toscrape.com/", max_pages=3)
df = pd.DataFrame(books)
print(len(df), "books")
df.to_csv("books.csv", index=False, encoding="utf-8")
60 books
  • requests.Session() reuses the connection: faster for you and kinder to the server.
  • max_pages keeps a pagination bug from leaving you downloading forever.
  • time.sleep(delay) waits between pages. A one- or two-second pause is basic courtesy.
Analyze what you extracted

You now have a DataFrame, so apply lesson 11. For example, the average price by rating: df.groupby("stars")["price"].mean().round(2), or the 5 most expensive books: df.nlargest(5, "price")[["title", "price"]].

2. What is time.sleep(1) for inside the scraper's loop?

  • To space out requests so you don't overload the site's server.
  • To let the page finish loading its JavaScript.
  • To save memory in Python.

Lots of back-to-back requests can overwhelm a small site and get your IP blocked. Pauses are part of responsible scraping.

When the page is built with JavaScript

Some sites send an almost empty page and then load the data with JavaScript. If you run requests.get and can't find the data you see in your browser, that's why. Your options, in order of preference:

1. Internal APIYour first move
How
In your browser's developer tools, open the Network tab, filter by Fetch/XHR and reload. The data often arrives as JSON you can request directly with requests.
Advantage
Cleaner, faster and more stable than HTML.
Preferred
2. Automated browserPlaywright or Selenium
How
They run the JavaScript like a real browser and hand you the finished page.
Downside
Slower and heavier: use them only when there's no other way.
Last resort
Scrapers break

When a site changes its design, your selectors stop finding the data. Always check that you're extracting what you expect (for example, 20 books per page) and make your program fail with a clear error if not, instead of quietly saving an empty CSV.

3. You download a page with requests, and the product list you see in your browser isn't in the HTML. What do you check first?

  • Whether your internet connection is slow.
  • Whether BeautifulSoup is up to date.
  • The browser's Network tab, to see if the data arrives as JSON from an internal API.

If the data loads with JavaScript, it isn't in the initial HTML. Requesting that JSON directly is usually the simplest, sturdiest fix.

Real case: auditing your own store's online catalog

Here's a use of scraping with no ethical gray areas: checking your own online store. Summit Coffee Roasters in Denver, Colorado, wants to catch out-of-stock products and products published without a price every week. With requests you'd download its catalog page; here we use a snippet of that HTML so the example works offline:

from bs4 import BeautifulSoup

# Snippet of your own store's catalog page: Summit Coffee Roasters (Denver, CO)
html = """
<div class="product"><h2>Colombia Huila 12 oz</h2><span class="price">$16.50</span><span class="stock">12</span></div>
<div class="product"><h2>Ethiopia Yirgacheffe 2 lb</h2><span class="price">$54.50</span><span class="stock">0</span></div>
<div class="product"><h2>French press 34 oz</h2><span class="stock">5</span></div>
<div class="product"><h2>Commercial grinder</h2><span class="price">$1,249.00</span><span class="stock">2</span></div>
"""
soup = BeautifulSoup(html, "html.parser")


def price_to_number(text):
    return float(text.replace("$", "").replace(",", "").strip())


products, no_price = [], []
for div in soup.select("div.product"):
    name = div.h2.get_text(strip=True)
    tag = div.select_one("span.price")
    if tag is None:                      # the selector found nothing
        no_price.append(name)
        continue
    products.append({"name": name,
                     "price": price_to_number(tag.get_text()),
                     "stock": int(div.select_one("span.stock").get_text())})

for p in sorted(products, key=lambda p: p["price"], reverse=True):
    status = "OUT OF STOCK" if p["stock"] == 0 else f"{p['stock']} left"
    print(f"{p['name']:26} ${p['price']:8,.2f}  {status}")
print("No price listed:", no_price)
Commercial grinder         $1,249.00  2 left
Ethiopia Yirgacheffe 2 lb  $   54.50  OUT OF STOCK
Colombia Huila 12 oz       $   16.50  12 left
No price listed: ['French press 34 oz']

The key is the line if tag is None. select_one() returns None when it finds nothing, and calling .get_text() on None crashes the program. This way the product without a price gets flagged for fixing on the website, and the rest of the report still runs. Notice price_to_number too: it turns "$1,249.00" into 1249.0 by stripping the dollar sign and comma, just like in lesson 3.

Common scraping errors

Without that check, you'd hit the most typical scraping error:

    price = div.select_one("span.price").get_text()
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'NoneType' object has no attribute 'get_text'

Read it as "the None object has no get_text." The ^^^ marks underline select_one(...): that selector found nothing. Either the element is missing on that listing, or the site changed its HTML and your selector is out of date.

Chaining select_one(...).get_text() without any check.

Store the result, check is None and decide: skip it, log it or raise an alert.

Using the visible text when it's truncated ("A Light in the ...").

Look for the full value in an attribute, like title or href.

Retrying over and over when the site answers 403 or 429.

Stop: the site doesn't allow access or you're going too fast. Increase the delay or find an official route.

Accepting an empty CSV as a success.

Validate the number of rows you expect with assert before saving.

4. Your scraper worked yesterday and today fails with AttributeError: 'NoneType' object has no attribute 'get_text' on the price line. What's most likely?

  • BeautifulSoup was uninstalled
  • The site changed its HTML and the price selector no longer finds anything
  • Prices now have more decimal places

select_one() returned None because nothing matched. Inspect the page again, update the selector and add a check so next time the program warns you instead of crashing.

Practice exercise: cheap, well-rated books

  1. Scrape the first 5 pages of books.toscrape.com with a 1-second delay.
  2. Check that you got 100 books and that none has a missing price.
  3. Keep the books with 4 or 5 stars priced under £20, sort them by price and save them to picks.csv.
See solution
books = scrape("https://books.toscrape.com/", max_pages=5, delay=1.0)
df = pd.DataFrame(books)

assert len(df) == 100, f"Expected 100 books, got {len(df)}"
assert df["price"].notna().all()

picks = (
    df[(df["stars"] >= 4) & (df["price"] < 20)]
    .sort_values("price")
    [["title", "price", "stars", "url"]]
)
picks.to_csv("picks.csv", index=False, encoding="utf-8")
print(len(picks), "recommended books")
print(picks.head())

assert stops the program with a clear message if something doesn't add up. It's the simplest way to learn that your scraper broke before anyone makes decisions with incomplete data.

Summary

  • Before scraping: look for an API, read the terms and robots.txt, don't overload the site and leave personal data alone.
  • requests downloads the page, BeautifulSoup parses it and CSS selectors find the data.
  • Convert text to numbers, complete links with urljoin and validate what you extract.
  • Crawl pages with a limit and a delay, and save the results with pandas.
  • If the data arrives through JavaScript, find the internal API in the Network tab or use Playwright.

Next lesson: Machine Learning with Python: Your First Model with scikit-learn. In the course's final lesson you'll train a model to predict sales and learn to judge whether its predictions are any good.

🎓 Save your progress and get a free certificate

Create your account in 30 seconds, mark your lessons and download your certificate at the end.

Enroll for free