import requests
url = "https://en.wikipedia.org/wiki/List_of_countries_by_population_(United_Nations)"
headers = {"User-Agent": "DATASCI350-course-materials/1.0 (danilo.freire@emory.edu)"}
r = requests.get(url, headers=headers, timeout=30)
r.raise_for_status()
# Save the page so we parse many times and fetch once
with open("data/scraping/wikipedia_population_un.html", "w", encoding="utf-8") as f:
f.write(r.text)Web Scraping with Python
Introduction
Web scraping is no longer taught in the lectures of DATASCI 350. Module 06 covers web APIs, the front door that most modern data providers offer, and Lectures 18 and 19 teach you how to use them well. This self-study tutorial covers the other case: the data you want sits on a web page, visible to any human, with no endpoint for it. Read it if you meet that situation in a project or a job, or if you simply want to know what scraping involves before you need it.
The tutorial pairs naturally with the web API lectures. The workflow you learned there, fetch, cache, extract, tidy, tabulate, is exactly the workflow here, only the extraction step changes. The final section, on using language models for extraction, builds on the AI lectures in Module 04 (Lectures 12 to 15), and it will make more sense if you have seen those.
The tutorial is self-contained. You will read a little HTML, pull tables into pandas with one line, walk a page with BeautifulSoup when there is no table, and think through the ethics and the law of scraping. All the code executes when the document is rendered, so every output you read below is real. The web pages it parses were fetched once and saved to disk, so the whole document renders without a network connection. The few cells that do reach the internet are marked as such and are not executed.
When there is no API
Everything in Module 06 assumed somebody built you a front door. Often nobody did. The data sits on a web page, readable by any person with a browser, and there is no endpoint for it. Scraping means writing a program that reads the page the way a person would, and pulls the data out of the HTML.
Before you write a single line of scraping code, work down this ladder and stop at the first rung that works:
- Look harder for an API. More sites have one than admit it. Section 3.5 shows a trick for finding hidden ones.
- Ask. Many organisations will send you the dataset if you email and explain what you need it for.
pd.read_html. If the data is in an HTML<table>, this is one line.- BeautifulSoup. When the data is in cards, lists, or divs, you walk the page yourself.
- Selenium or Playwright. When JavaScript draws the page and there is no hidden API, you need a real browser under program control. This tutorial stops before that rung.
- Stop. Some pages should not be scraped, and knowing which is part of the skill.
The ladder matters. Each step down costs you more time and produces more fragile code.
How a web page is built
HTML in ten minutes
Here is a small but complete web page:
<html>
<body>
<h1>Reading list</h1>
<p class="intro">Books for the course.</p>
<div class="book" id="b1">
<h3><a href="/b1">Invisible Cities</a></h3>
<span class="price">£12.50</span>
</div>
<div class="book" id="b2">
<h3><a href="/b2">The Order of Time</a></h3>
<span class="price">£9.99</span>
</div>
</body>
</html>A tag is written <name>content</name>. <p> is a paragraph and <h1> is a heading. Tags nest: the <a> sits inside the <h3>, which sits inside the <div>. Attributes live in the opening tag as name="value". Two attributes do almost all the work in scraping. class groups elements that look alike, and many elements share a class. id names one element, and an id should be unique on the page. So class finds you a set of things, and id finds you one thing. The href attribute on an <a> holds the link target.
The only tags you need for this tutorial are <table>, <tr>, <td>, <th> (tables), <a href> (links), <div class> and <span> (containers), <h1> to <h3> (headings), <p> (paragraphs), <ul> and <li> (lists), and <img src> (images, self-closing, so there is no </img>).
The page is a tree
Because tags nest and never overlap, an HTML document is a tree. Browsers call it the DOM, the Document Object Model. Our reading list looks like this as a tree:
html
`-- body
|-- h1 "Reading list"
|-- p.intro "Books for the course."
|-- div.book#b1
| |-- h3
| | `-- a[href] "Invisible Cities"
| `-- span.price "£12.50"
`-- div.book#b2
|-- h3
| `-- a[href] "The Order of Time"
`-- span.price "£9.99"
Scraping is walking this tree, and every scraping task reduces to the same two questions. Which branch holds one record? Here it is div.book, and there are two of them, so there are two records. Where inside that branch is each field? The title is in h3 a, and the price is in span.price. Answer those two questions and the code writes itself. Get them wrong and no amount of clever code saves you.
The figure below answers both questions for the real front page of books.toscrape.com. The solid red box is one record, the dashed boxes are its repeating siblings, and the two coloured boxes inside are the fields. Every scraping job you will ever write starts by drawing these boxes, whether on paper or in your head.

Finding the branch: your browser’s inspector
You do not read the HTML source top to bottom. You ask the browser. Right-click on the data you want, then choose Inspect (the option exists in Chrome, Firefox, Edge, and Safari). A panel opens with the DOM tree, scrolled to the element you clicked. Three things to do once you are there:
- Hover over lines in the panel. The matching part of the page lights up. Find the branch that holds one whole record.
- Read the class names on that branch and on the fields inside it. Those become your selectors.
- Walk up the tree until the highlight covers exactly one record and no more. That is your unit of observation.

article.product_pod in the tree lights up exactly one card and no more, so that is the branch holding one record. Its children name the fields: h3 > a is the title, product_price the price.Practise on https://books.toscrape.com before you try a real site. We return to it in Section 6.
CSS selectors: the minimum you need
A selector is a small pattern that picks elements out of the tree. The same syntax works in your browser’s inspector, in BeautifulSoup, and in CSS itself.
| Selector | Reads as | Matches in our example |
|---|---|---|
div |
every div element |
both books |
.book |
every element with class book |
both books |
#b1 |
the element with id b1 |
the first book only |
div.book |
div elements that also have class book |
both books |
div.book h3 |
any h3 inside a div.book |
both titles |
h3 > a |
an a that is a direct child of an h3 |
both links |
a[href] |
a elements that have an href attribute |
both links |
p[class="intro"] |
p whose class is exactly intro |
the intro paragraph |
A space means “somewhere inside”, and > means “directly inside”. That single distinction causes most selector confusion. Test a selector in the inspector’s search box before you put it in your code. A fuller cheat sheet appears in Section 9.
The trick worth knowing: check the network tab
Many pages that look impossible to scrape load their data as JSON behind the scenes. The HTML arrives nearly empty, then JavaScript fetches the real data and draws it. Scraping that HTML gets you nothing, because at the moment you fetched it there was nothing there. Many “single-page apps” (built with React, Vue, or Angular) work entirely this way: a shell of HTML plus API calls. The data you see on screen was never in the HTML at all.
Here is how to find the real source:
- Open developer tools. Select the Network tab.
- Tick the Fetch/XHR filter to hide images and stylesheets.
- Reload the page. Click through the requests that appear.
- Look for a request that returns JSON matching the data on screen. If you find one, you have found an undocumented API.
- Copy the URL. Call it with
requests.
The figure below is that hunt succeeding on https://quotes.toscrape.com/scroll, a page that loads its quotes as you scroll. The Fetch/XHR filter is on, so only data calls are listed, and two of them are visible. The Response pane shows what the first one returned: clean JSON, with has_next telling you whether more pages exist and text holding the very quote the page had just drawn on screen. There was no need to parse any HTML at all.

This is worth ten minutes before any scraping job. An undocumented endpoint is far better than parsing HTML, because a site redesign breaks selectors but usually leaves the data feed alone. Right-clicking a request and choosing “Copy as cURL” gives you the exact command, including headers and cookies, which you can translate to Python.
Setup
The tutorial uses four Python packages. The instructions below assume you have a working Python environment from earlier in the course.
Install the packages first.
pip install requests beautifulsoup4 lxml pandasrequests fetches pages, beautifulsoup4 parses them, lxml is the parser that pd.read_html needs, and pandas holds the results.
The tutorial parses pages that were fetched once and saved to disk, so you can run every example offline. Download the data/scraping folder from the tutorials folder of the course repository. Place it in a folder named data/scraping beside your script. Confirm that the path data/scraping/books_page1.html exists.
Each worked example also shows the fetch code that created those files, marked “not executed”, so you can see exactly how a page gets from the web to your disk.
The fast path: pd.read_html
If the data sits in an HTML <table>, you do not need BeautifulSoup at all. pd.read_html finds every table on a page and hands you a list of DataFrames.
import pandas as pd
url = "https://en.wikipedia.org/wiki/List_of_countries_by_population_(United_Nations)"
tables = pd.read_html(url)
print(len(tables))
df = tables[0]Always print len(tables) first. A Wikipedia article often has navigation boxes and infoboxes that are technically tables, so the one you want is rarely the only one and not always the first.
A word on the modern idiom: passing a string of HTML directly to read_html was deprecated and then removed. Give it a URL, a file path, or an open file object. If you have HTML in a variable, wrap it in io.StringIO.
Worked example: world population from Wikipedia
Here is the live fetch, shown but not executed. Note the User-Agent header: Wikipedia asks that automated clients identify themselves, and a project name with an email address is the polite way to do it.
And here is the version that runs, reading the saved page. read_html accepts a file path.
import pandas as pd
tables = pd.read_html("data/scraping/wikipedia_population_un.html")
print(f"{len(tables)} tables on the page")
df = tables[0]
print(df.shape)
for col in df.columns:
print(" ", col)2 tables on the page
(239, 6)
Country or territory
Population (1 July 2022)
Population (1 July 2023)
Change (%)
UN continental region[1]
UN statistical subregion[1]
The cleaning is the real work
Look at what arrived.
df.head(4).iloc[:, :3] Country or territory Population (1 July 2022) Population (1 July 2023)
0 NaN NaN NaN
1 World 8.021407e+09 8.091735e+09
2 India 1.425423e+09 1.438070e+09
3 China[a] 1.425180e+09 1.422585e+09
Column names carry footnote markers, the population columns may have come in as text, and the first row is a total rather than a country. This is normal. A table built to be read by a person is not a dataset. The cleaning below is the real work of read_html scraping:
import re
clean = df.copy()
# 1. Column names: strip footnote markers like [1] and collapse whitespace
clean.columns = [re.sub(r"\[.*?\]", "", str(c)).strip() for c in clean.columns]
# 2. Keep the first four columns and give them names we chose ourselves
clean = clean.iloc[:, :4]
clean.columns = ["country", "pop_2022", "pop_2023", "change_pct"]
# 3. Strip footnotes from the country names too
clean["country"] = (clean["country"].astype(str)
.str.replace(r"\[.*?\]", "", regex=True).str.strip())
# 4. Population columns to numbers: remove commas, then coerce
for col in ["pop_2022", "pop_2023"]:
clean[col] = pd.to_numeric(
clean[col].astype(str).str.replace(r"[^\d.]", "", regex=True),
errors="coerce"
)
# 5. The change column: Wikipedia writes negatives with a Unicode
# minus sign (U+2212), which Python does not read as a minus.
# Swap it for a plain hyphen, drop the %, then coerce
clean["change_pct"] = pd.to_numeric(
clean["change_pct"].astype(str)
.str.replace("\u2212", "-", regex=False).str.rstrip("%"),
errors="coerce"
)
print(clean.head(6).to_string(index=False))
print(f"\n{len(clean)} rows, dtypes: {clean['pop_2023'].dtype}") country pop_2022 pop_2023 change_pct
NaN NaN NaN NaN
World 8021407192.0 8091734930.0 0.88
India 1425423212.0 1438069596.0 0.89
China 1425179569.0 1422584933.0 -0.18
United States 341534046.0 343477335.0 0.57
Indonesia 278830529.0 281190067.0 0.85
239 rows, dtypes: float64
Checking your work
Never trust a scrape you have not sanity-checked.
print(f"rows parsed: {len(clean)}")
print(f"missing pop_2023: {clean['pop_2023'].isna().sum()}")
print()
print("Largest by 2023 population:")
print(clean.nlargest(5, "pop_2023")[["country", "pop_2023"]]
.to_string(index=False))rows parsed: 239
missing pop_2023: 1
Largest by 2023 population:
country pop_2023
World 8091734930.0
India 1438069596.0
China 1422584933.0
United States 343477335.0
Indonesia 281190067.0
Look at that top row. World is not a country, and it would have quietly doubled every total you computed.
The row that was not a country
Tables written for people often carry totals, subtotals, and notes in the same shape as the data. Drop them on purpose.
countries = clean[clean["country"] != "World"].copy()
print(f"before: {len(clean)} rows after: {len(countries)} rows")
print()
print(countries.nlargest(5, "pop_2023")[["country", "pop_2023"]]
.to_string(index=False))
# Does the rest now add up to roughly the World figure we removed?
world = clean.loc[clean["country"] == "World", "pop_2023"].iloc[0]
total = countries["pop_2023"].sum()
print(f"\nsum of countries: {total:,.0f}")
print(f"World row said: {world:,.0f}")
print(f"difference: {abs(total - world) / world:.2%}")before: 239 rows after: 238 rows
country pop_2023
India 1438069596.0
China 1422584933.0
United States 343477335.0
Indonesia 281190067.0
Pakistan 247504495.0
sum of countries: 8,090,964,927
World row said: 8,091,734,930
difference: 0.01%
That last check is the useful habit: the total you removed is a free test of the rows you kept. A scrape that returns a DataFrame is not a scrape that worked. It is a scrape that returned a DataFrame.
Try it yourself
Scrape the Wikipedia article List of countries by GDP (nominal). The file wikipedia_gdp_nominal.html in data/scraping/ is a saved copy, so you can work offline. Find the main table, the one listing countries with IMF, World Bank, and United Nations estimates, and report the IMF estimate for Brazil, in millions of US dollars.
Two hints. read_html returns a list, and this page has more tables than you expect, so print its length first, then look at the .shape and columns of each one until you find the right table. And the column headers on that page span two rows, so pandas gives you a MultiIndex. Printing df.columns will show you what you are dealing with.
If you fetch the live page instead of the saved copy, send a User-Agent header, as in the worked example. The solution is in Section 11.
BeautifulSoup
Most of the web is not <table> elements. Data comes as cards, lists, panels, and nested divs. BeautifulSoup parses HTML into a tree you can search.
from bs4 import BeautifulSoup
soup = BeautifulSoup(html_text, "html.parser")The second argument is the parser. "html.parser" ships with Python and is fine. "lxml" is faster and more forgiving of broken markup. Either way, soup is now the root of the tree, and everything that follows is searching it.
find and find_all
The two basic search methods take a tag name and, optionally, attribute filters.
from bs4 import BeautifulSoup
html = """
<div class="book" id="b1">
<h3><a href="/b1">Invisible Cities</a></h3>
<span class="price">£12.50</span>
</div>
<div class="book" id="b2">
<h3><a href="/b2">The Order of Time</a></h3>
<span class="price">£9.99</span>
</div>
"""
soup = BeautifulSoup(html, "html.parser")
print(type(soup.find("div"))) # the FIRST match, or None
print(len(soup.find_all("div"))) # ALL matches, as a list
# class_ has a trailing underscore, because 'class' is a Python keyword
books = soup.find_all("div", class_="book")
print(len(books), "books")<class 'bs4.element.Tag'>
2
2 books
find returns one element or None. find_all returns a list, possibly empty. Confusing the two is the most common beginner error, because None and [] fail in different places.
Getting text and attributes out
Once you hold an element, you want the text between its tags or the value of an attribute.
first = books[0]
# Text between the tags
print(repr(first.find("a").get_text()))
print(repr(first.find("a").get_text(strip=True))) # strip surrounding whitespace
# Attribute values: two ways, one of which does not crash
print(first.find("a")["href"]) # KeyError if the attribute is missing
print(first.find("a").get("href")) # None if the attribute is missing
print(first.get("id"), first.get("data-nonexistent"))'Invisible Cities'
'Invisible Cities'
/b1
/b1
b1 None
.get_text(strip=True) is what you want almost every time, because HTML is full of newlines and indentation that you do not want in your data. And prefer .get("attr") over ["attr"] in a loop: one missing attribute should not stop a scrape of a thousand records.
select: the same selectors as your browser
select() takes the CSS selectors from Section 3.4. select_one() is its find equivalent.
print(len(soup.select("div.book")))
print(soup.select_one("div.book h3 a").get_text(strip=True))
print(soup.select_one("#b2 .price").get_text(strip=True))
print([a.get("href") for a in soup.select("div.book h3 > a")])2
Invisible Cities
£9.99
['/b1', '/b2']
find_all("div", class_="book") and select("div.book") do the same job. Prefer select for two reasons: you can paste the selector straight from your browser’s inspector, and one string expresses a path that find_all needs several chained calls to reach. Keep find_all for when you want to filter on something CSS cannot express.
Worked example: books.toscrape.com
https://books.toscrape.com is a fake bookshop built specifically for practising scraping. It has 1,000 books, 20 per page across 50 pages. The prices and ratings are random and mean nothing. Its banner says “We love being scraped!”, which is as clear a permission as you will ever get. Practise here, not on a real shop.
Step one is always the same: fetch the page once and save it. This is the fetch that created the cached copy, shown but not executed.
import requests
url = "https://books.toscrape.com/catalogue/page-1.html"
headers = {"User-Agent": "DATASCI350-course/1.0 (danilo.freire@emory.edu)"}
r = requests.get(url, headers=headers, timeout=15)
r.raise_for_status()
with open("data/scraping/books_page1.html", "wb") as f:
f.write(r.content)A trap worth meeting now: encoding
Fetch that page, print a price, and you may see £51.77 instead of £51.77. Here is why. The server sends Content-Type: text/html with no charset. The HTTP specification says that when no charset is given, the client should assume ISO-8859-1, and requests follows the rule. But the page is actually UTF-8, so the pound sign gets mangled, and later float(price.replace("£", "")) fails with an error that mentions none of this.
r = requests.get(url, headers=headers, timeout=15)
print(r.encoding) # ISO-8859-1
print(r.apparent_encoding) # utf-8
r.encoding = "utf-8" # the fix: one line
# or save r.content (raw bytes) to disk and decode when you read the fileWhen text comes out looking like £ or é, the problem is always encoding, never your parser. Saving r.content (raw bytes) rather than r.text (decoded text) sidesteps it entirely, which is why the fetch above wrote r.content and opened the file in "wb" mode.
Step 1: find the branch that holds one record
Now we parse the saved page and answer the first of the two questions from Section 3.2.
from bs4 import BeautifulSoup
with open("data/scraping/books_page1.html", encoding="utf-8") as f:
soup = BeautifulSoup(f.read(), "html.parser")
cards = soup.select("article.product_pod")
print(f"{len(cards)} book cards on this page\n")
snippet = cards[0].prettify()[:600]
print("\n".join(line[:72] for line in snippet.splitlines())) # trim long lines20 book cards on this page
<article class="product_pod">
<div class="image_container">
<a href="a-light-in-the-attic_1000/index.html">
<img alt="A Light in the Attic" class="thumbnail" src="../media/cache
</a>
</div>
<p class="star-rating Three">
<i class="icon-star">
</i>
<i class="icon-star">
</i>
<i class="icon-star">
</i>
<i class="icon-star">
</i>
<i class="icon-star">
</i>
</p>
<h3>
<a href="a-light-in-the-attic_1000/index.html" title="A Light in the A
A Light in the ...
</a>
</h3>
<div class="product_price">
<p class="p
The branch is article.product_pod, and there are twenty of them, one per book.
Step 2: locate each field inside the branch
card = cards[0]
print("title (visible text):", card.select_one("h3 a").get_text(strip=True))
print("title (attribute) :", card.select_one("h3 a").get("title"))
print("price :",
card.select_one("p.price_color").get_text(strip=True))
print("rating (class list) :", card.select_one("p.star-rating").get("class"))
print("link :", card.select_one("h3 a").get("href"))title (visible text): A Light in the ...
title (attribute) : A Light in the Attic
price : £51.77
rating (class list) : ['star-rating', 'Three']
link : a-light-in-the-attic_1000/index.html
There are two lessons in that output. First, the visible title is truncated with an ellipsis, and the full one is in the title attribute. What a human reads is not always what you want to store. Second, the rating is not text at all. It is encoded in the class name, star-rating Three, and the stars you see are drawn by CSS. Data hides in attributes as often as in text.
Step 3: the universal recipe
import pandas as pd
WORDS = {"One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5}
def parse_books(soup):
"""Every book card on one page, as a list of dicts."""
rows = []
for card in soup.select("article.product_pod"):
rating = card.select_one("p.star-rating").get("class")[1]
rows.append({
"title": card.select_one("h3 a").get("title"),
"price": float(card.select_one("p.price_color")
.get_text(strip=True).lstrip("£")),
"rating": WORDS.get(rating),
"in_stock": "In stock" in
card.select_one("p.instock.availability").get_text(),
"url": card.select_one("h3 a").get("href"),
})
return rows
books_df = pd.DataFrame(parse_books(soup))
print(books_df.head(5).to_string(index=False, max_colwidth=30))
print(f"\n{len(books_df)} books, mean price £{books_df['price'].mean():.2f}") title price rating in_stock url
A Light in the Attic 51.77 3 True a-light-in-the-attic_1000/i...
Tipping the Velvet 53.74 1 True tipping-the-velvet_999/inde...
Soumission 50.10 1 True soumission_998/index.html
Sharp Objects 47.82 4 True sharp-objects_997/index.html
Sapiens: A Brief History of... 54.23 5 True sapiens-a-brief-history-of-...
20 books, mean price £38.05
That recipe again, in words. Every scraper you will ever write has these five steps:
- Fetch the page once, and save it to disk.
- Parse the saved text into a soup.
- Select the elements that each hold one record.
- Extract the fields from inside each one, into a dict.
- Tabulate: a list of dicts becomes a DataFrame in one line.
Steps 3 and 4 are the only ones that change between sites. Everything else is boilerplate you will copy from job to job. Notice how closely this mirrors the API workflow from Lecture 18: fetch, cache, extract, tidy, tabulate. The shape of the work is the same whether or not there is an API.
Scraping several pages
Real jobs rarely stop at one page. Here is the loop that fetched three pages of the bookshop, again shown but not executed.
import time
import requests
headers = {"User-Agent": "DATASCI350-course/1.0 (danilo.freire@emory.edu)"}
all_rows = []
for page in range(1, 4):
url = f"https://books.toscrape.com/catalogue/page-{page}.html"
r = requests.get(url, headers=headers, timeout=15)
r.raise_for_status()
with open(f"data/scraping/books_page{page}.html", "wb") as f:
f.write(r.content)
soup = BeautifulSoup(r.content.decode("utf-8"), "html.parser")
rows = parse_books(soup)
print(f"page {page}: {len(rows)} books")
all_rows.extend(rows)
time.sleep(1) # one second between pagestime.sleep(1) is the same courtesy you learned for APIs, and it matters more here. An API expects programmatic traffic; a website was built for people. Notice also that the loop saves each page as it goes. Run it once, then develop your parser against the files, which is what the next cell does.
all_rows = []
for page in range(1, 4):
with open(f"data/scraping/books_page{page}.html", encoding="utf-8") as f:
page_soup = BeautifulSoup(f.read(), "html.parser")
rows = parse_books(page_soup)
print(f"page {page}: {len(rows)} books")
all_rows.extend(rows)
three = pd.DataFrame(all_rows)
print(f"\n{len(three)} books, mean price £{three['price'].mean():.2f}")
print(f"rating counts: {three['rating'].value_counts().sort_index().to_dict()}")page 1: 20 books
page 2: 20 books
page 3: 20 books
60 books, mean price £35.00
rating counts: {1: 15, 2: 8, 3: 13, 4: 10, 5: 14}
Three pages, 60 books, no network. This is how the tutorial you are reading was rendered.
Try it yourself
Scrape page 1 of the Mystery category on books.toscrape.com. The live URL is https://books.toscrape.com/catalogue/category/books/mystery_3/index.html, and a saved copy is included at data/scraping/books_mystery.html. Extract the title and price of every book on that page, put them in a DataFrame, and report the mean price to two decimal places. Then report how many books are on the page, and check it against what the page itself says.
Two hints. The elements you need carry the classes product_pod and price_color; work out for yourself which one is the record and which one is the field. And the price arrives as text with a currency symbol in front, so it will not average until it is a number.
If you fetch the live page, save it to disk before you start experimenting on it. The solution is in Section 11.
Ethics, law, and reliability
robots.txt
Almost every site publishes a file at /robots.txt saying which paths automated clients should leave alone. Fetch https://en.wikipedia.org/robots.txt and you will find a long, well-commented one. A saved copy ships with the tutorial.
with open("data/scraping/wikipedia_robots.txt", encoding="utf-8") as f:
lines = [l for l in f.read().splitlines()
if l.startswith(("User-agent", "Disallow", "Allow", "Crawl-delay"))]
print(f"{len(lines)} rules in Wikipedia's robots.txt\n")
print("\n".join(lines[:14]))499 rules in Wikipedia's robots.txt
User-agent: MJ12bot
Disallow: /
User-agent: Mediapartners-Google*
Disallow: /
User-agent: IsraBot
Disallow:
User-agent: Orthogaffe
Disallow:
User-agent: UbiCrawler
Disallow: /
User-agent: DOC
Disallow: /
User-agent: Zao
Disallow: /
User-agent says which clients the block applies to, and * means everyone. Disallow lists paths to stay away from. Crawl-delay, where present, asks for a pause between requests. Python has a reader in the standard library:
from urllib.robotparser import RobotFileParser
rp = RobotFileParser()
rp.set_url("https://en.wikipedia.org/robots.txt")
rp.read()
print(rp.can_fetch("*", "https://en.wikipedia.org/wiki/Python"))Three things to understand about robots.txt. It is a courtesy standard, not a technical barrier and not a law, so nothing stops you ignoring it; in this course you honour it. Absence is not permission: books.toscrape.com has no robots.txt at all, which means nobody wrote one, not that scraping is welcome (its banner grants that separately). And Google, Bing, and other search engines obey it. If a search engine respects a site’s wishes, so should your script.
Terms of service and the law
This is not legal advice, and I am not a lawyer. Here is the honest shape of it.
The most cited case is hiQ Labs v. LinkedIn. hiQ scraped public LinkedIn profiles, and LinkedIn tried to stop them using the Computer Fraud and Abuse Act (CFAA), the main US anti-hacking statute. The Ninth Circuit held that scraping publicly available data is unlikely to be unauthorised access under the CFAA, first in 2019 and again on remand in 2022 (United States Court of Appeals for the Ninth Circuit 2022).
People stopped reading there, and they should not have. In December 2022 the case settled: hiQ accepted a permanent injunction, agreed to delete the scraped data, and paid LinkedIn $500,000. The lesson is precise: not a crime is not the same as allowed. hiQ won on the criminal statute and still lost on breach of contract, because it had agreed to LinkedIn’s terms of service.
Four things constrain you regardless of the CFAA:
- Terms of service, especially if you hold an account or clicked to agree.
- Copyright. The facts in a page are generally not protected. The text and images generally are.
- Personal data. GDPR, FERPA, and similar rules apply to information about people, whoever collected it.
- Load. Making a site slower for its actual users is bad behaviour, whatever the law says.
A working rule
Scraping public, factual, non-personal data at a gentle rate, from a site whose robots.txt allows it, for research or teaching, is normal practice and what this tutorial does. Everything outside that description deserves a pause and a question.
Never scrape personal data for coursework. Not names, not profiles, not anything about identifiable individuals. If you are unsure, ask me. If you are unsure and it is for something real, ask a lawyer. And ask first whether you need to scrape at all: many organisations will send you the dataset if you email and explain what you are doing.
Politeness engineering
Four habits cost almost nothing and prevent almost every problem.
Identify yourself. Put a real
User-Agentwith your project name and an email address. If you cause a problem, someone can contact you rather than simply blocking you.headers = {"User-Agent": "DATASCI350-course/1.0 (danilo.freire@emory.edu)"}Throttle. Sleep for a second between pages with
time.sleep(1). If the site publishes aCrawl-delay, use that instead.Cache raw HTML to disk. You will parse a page twenty times while developing. Fetch it once.
Take only what you need. Three pages beat fifty when three answer your question.
Habit 3 is the one people skip and regret. It makes you faster, makes your work reproducible, and means a site redesign halfway through your project does not destroy your data.
Why scrapers rot
An API is a promise. HTML is not a promise, it is a design decision, and designs change. A site redesign renames product_pod to product-card, and your select quietly returns []. The dangerous word is quietly. You do not get an exception. You get an empty list, an empty DataFrame, and a plot with no points, and you spend an hour blaming pandas.
The defence is to fail loudly: check that you found something, and that you found roughly what you expected.
cards = soup.select("article.product_pod")
if not cards:
raise ValueError("No book cards found. Has the page structure changed?")
assert len(cards) == 20, f"Expected 20 books per page, found {len(cards)}"
print(f"{len(cards)} cards found, as expected")20 cards found, as expected
Three more defensive habits. Log what you got, printing counts per page while the scrape runs. Pin a copy of the page: the Wayback Machine keeps snapshots, and you can scrape an archived version when the live page changes. And re-run your scraper occasionally, because scrapers rot even when nobody touches them.
Which tool, and when
| Situation | Reach for | Why |
|---|---|---|
| A documented API exists | requests + JSON |
Stable, fast, explicitly permitted |
| No documented API, but the Network tab shows JSON | requests on that URL |
An undocumented API still beats HTML |
The data is in an HTML <table> |
pd.read_html |
One line, then clean |
| The data is in cards, lists, or divs | BeautifulSoup | Full control over the tree |
| The page draws itself with JavaScript, and there is no JSON to find | Playwright or Selenium | A real browser. Slow, heavy, a last resort |
Personal data, or robots.txt says no, or the ToS forbids it |
Stop | Ask, or find another source |
Work down this table from the top. Most people start at row four and never discover that row one was available.
Language models and scraping
The AI lectures in Module 04 taught you structured-output prompting and coding agents. Both apply directly to scraping, in two distinct patterns, and the difference between them matters.
Pattern 1: the model as a parser
Paste a messy chunk of HTML into a chat model and ask for a specific structure back.
Here is the HTML of a product listing. Return a JSON array where each element
has exactly these keys: title (string), price (number, no currency symbol),
rating (integer 1-5). Return only the JSON, no commentary.
<article class="product_pod">...</article>
This is the structured-output prompting from Lecture 12, applied to a new problem, and it genuinely works. Where it fits: one-off jobs, ugly pages, formats too irregular for a selector, and quick exploration when you are not sure what is in the page. Where it does not: 1,000 pages. You pay per token, you wait per call, and the model will occasionally invent a plausible value rather than admit a field was missing. A selector that returns nothing is obviously broken. A model that returns something wrong is not.
Pattern 2: the model writes the scraper
The better use. Give a coding agent, of the kind you met in Lecture 14, the page and a description, and ask for the code.
Write a Python function using requests and BeautifulSoup that scrapes
books.toscrape.com and returns a DataFrame with title, price, and rating.
The rating is in the class attribute of p.star-rating, as a word.
Cache the HTML to disk. Include a check that raises if no books are found.
Now the model’s output is code you can read, test, and version-control, and it runs a thousand times for free. The caveat is not optional: you must still verify the output. Models invent selectors that look right. A class name that does not exist on the page produces an empty list, and an empty list is exactly what a rotted scraper produces. Notice too that the prompt above contains the class name. I could write that prompt because I had already inspected the page.
What this means for how you work
Models are good at the boilerplate: the fetch loop, the caching, the DataFrame assembly, the error handling. They are unreliable at the part that requires looking at this particular page and deciding which branch holds one record. That is the skill this tutorial teaches, and you cannot check the delegation without it. The honest summary: a model makes a competent scraper faster. It does not make an inexperienced one competent.
Whenever a model writes or parses for you, run this checklist:
- Run the scraper.
- Print the DataFrame shape and dtypes.
- Check five rows against the page with your own eyes.
- Check the row count against the page’s own claim, if the page makes one.
- Look for
NaNwhere you expect data. - Look for data where you expect
NaN.
Every time, no exceptions. A scraper that returns a plausible-looking DataFrame is the most dangerous kind of wrong.
Selector cheat sheet
Keep this page as a reference. The left column works in select() and in your browser’s inspector; the right column is the find/find_all equivalent.
| Goal | CSS selector (select) |
find_all equivalent |
|---|---|---|
| Every paragraph | p |
find_all("p") |
Class price |
.price |
find_all(class_="price") |
Id main |
#main |
find(id="main") |
div with class book |
div.book |
find_all("div", class_="book") |
| Two classes at once | p.instock.availability |
find_all("p", class_=["instock", "availability"]) |
| Descendant, any depth | div.book a |
chained find_all |
| Direct child only | ul > li |
find_all("li", recursive=False) |
| Has an attribute | a[href] |
find_all("a", href=True) |
| Attribute equals | a[href="/b1"] |
find_all("a", href="/b1") |
| Attribute starts with | a[href^="/books"] |
needs a regex |
| Attribute contains | a[href*="page"] |
needs a regex |
| First match only | select_one(...) |
find(...) |
And for extraction, once you hold an element:
| Goal | Code |
|---|---|
| Text inside an element | el.get_text(strip=True) |
| An attribute, safely | el.get("href") |
| An attribute, strictly | el["href"] (raises if missing) |
| The class list | el.get("class") returns a list |
| Readable HTML, for debugging | el.prettify() |
Going further
The habits matter more than the libraries. Look for an API before you scrape. Look at the response before you parse it. Fetch once, save, and work from the file. Sleep between requests, and say who you are. Check that you got what you expected. Write it all as a script, so the data can be rebuilt. When in doubt, email the data owner and ask.
To go deeper:
- The BeautifulSoup documentation is short and readable, and covers the tree-navigation methods this tutorial skipped.
- The pandas
read_htmlreference lists the arguments that handle headers, missing values, and number parsing at read time. - books.toscrape.com and its sibling quotes.toscrape.com are safe playgrounds for every technique here, including pagination and login forms.
- Playwright for Python is the tool to learn when you meet a JavaScript-drawn page with no hidden API, the fifth rung of the ladder.
The workflow you practised here feeds the same pipeline as the rest of the course: once the data is a DataFrame, you save it to Parquet, and the scaling tools from Lectures 21 and 22 take over from there.
Solutions to exercises
Try each exercise before you read its solution.
Wikipedia GDP table
The lesson of this exercise is the loop that prints every table’s shape. You do not guess the index, you look.
import pandas as pd
tables = pd.read_html("data/scraping/wikipedia_gdp_nominal.html")
print(f"{len(tables)} tables found\n")
# Look at each one until you find the shape you want
for i, t in enumerate(tables):
print(f" [{i}] shape {t.shape}")8 tables found
[0] shape (1, 1)
[1] shape (1, 2)
[2] shape (222, 4)
[3] shape (14, 3)
[4] shape (8, 2)
[5] shape (9, 2)
[6] shape (13, 2)
[7] shape (2, 2)
The main country table is the one with roughly 200 rows. The headers span two rows, so pandas builds a MultiIndex; flatten it, then find the country column and the IMF column by name rather than by position.
gdp = next(t for t in tables if t.shape[0] > 150)
# Flatten the MultiIndex into single strings
gdp.columns = [" ".join(str(c) for c in col).strip()
if isinstance(col, tuple) else str(col)
for col in gdp.columns]
for c in gdp.columns[:5]:
print(" ", c)
country_col = gdp.columns[0]
imf_col = next(c for c in gdp.columns if "IMF" in c)
brazil = gdp[gdp[country_col].astype(str).str.contains("Brazil", na=False)]
print()
print(brazil[[country_col, imf_col]].to_string(index=False)) Country/Territory
IMF (2026)[1]
World Bank (2025)[6]
United Nations (2024)[7]
Country/Territory IMF (2026)[1]
Brazil 2635912
Mystery books
The fetch that created the cached copy, not executed here:
import requests
url = "https://books.toscrape.com/catalogue/category/books/mystery_3/index.html"
headers = {"User-Agent": "DATASCI350-course/1.0 (danilo.freire@emory.edu)"}
r = requests.get(url, headers=headers, timeout=15)
r.raise_for_status()
with open("data/scraping/books_mystery.html", "wb") as f:
f.write(r.content) # bytes, so the encoding trap cannot biteAnd the parse, which runs on the saved page:
from bs4 import BeautifulSoup
import pandas as pd
with open("data/scraping/books_mystery.html", encoding="utf-8") as f:
soup = BeautifulSoup(f.read(), "html.parser")
cards = soup.select("article.product_pod")
if not cards:
raise ValueError("No book cards found. Has the page structure changed?")
rows = [{"title": c.select_one("h3 a").get("title"),
"price": float(c.select_one("p.price_color")
.get_text(strip=True).lstrip("£"))}
for c in cards]
mystery = pd.DataFrame(rows)
print(f"{len(mystery)} books on the page")
print(f"mean price: £{mystery['price'].mean():.2f}")
print(f"cheapest: £{mystery['price'].min():.2f} "
f"dearest: £{mystery['price'].max():.2f}")
print()
print(mystery.nsmallest(5, "price").to_string(index=False, max_colwidth=40))20 books on the page
mean price: £32.79
cheapest: £10.69 dearest: £59.48
title price
Tastes Like Fear (DI Marnie Rome #3) 10.69
Hide Away (Eve Duncan #20) 11.84
Playing with Fire 13.71
That Darkness (Gardiner and Renner #1) 13.92
A Murder in Time 16.64
The page header says “Mystery” with a count. Checking your row count against what the page claims is the cheapest bug-catcher there is.
That is the end of the tutorial. You read HTML as a tree, pulled a Wikipedia table into pandas and cleaned it, walked a bookshop page with BeautifulSoup, and met the case, hiQ Labs v. LinkedIn, that defines where the legal lines sit. Keep the selector cheat sheet in Section 9 to hand, and start every future job at the top of the ladder in Section 2.