Web Scraping 🕸️
When there is no API, the data is still there, in the page. Here is how to get it, and the rules that keep you out of trouble.
The rules, before the code
Scraping is legal in many places and not in others, and the deciding factors are usually the site's terms of service, what you do with the data, and whether you caused harm. This is not legal advice. What follows is the professional standard of behaviour.
- Look for an API first. Most sites worth scraping have one. It will be faster, more reliable and explicitly permitted.
- Read
/robots.txt. It tells you which paths the operator does not want automated access to. It is not legally binding everywhere, and ignoring it is bad faith. - Read the terms of service. Many explicitly prohibit automated collection.
- Rate limit yourself. One request every second or two. You are a guest on someone else's hardware, and they pay for it.
- Identify yourself in the User-Agent, with a way to contact you.
- Never scrape personal data without a lawful basis. GDPR and similar laws apply to you even when the data is publicly visible.
- Cache aggressively. Fetch once, parse many times while developing.
import urllib.robotparser
rules = urllib.robotparser.RobotFileParser()
rules.set_url("https://example.com/robots.txt")
rules.read()
if rules.can_fetch("my-scraper", "https://example.com/products"):
print("allowed")
else:
print("robots.txt says no. Stop.")
Parsing HTML
from bs4 import BeautifulSoup # pip install beautifulsoup4
html = """
<html><body>
<h1 class="title">Stan's Previously Owned Vessels</h1>
<ul id="ships">
<li class="ship" data-id="1"><span class="name">Sea Monkey</span>
<span class="price">£4,000</span></li>
<li class="ship" data-id="2"><span class="name">Flying Dutchman</span>
<span class="price">£12,500</span></li>
</ul>
<a href="/page/2">Next</a>
</body></html>
"""
soup = BeautifulSoup(html, "html.parser")
print(soup.h1.text)
print(soup.find("span", class_="name").text)
for ship in soup.find_all("li", class_="ship"):
name = ship.find("span", class_="name").text
price = ship.find("span", class_="price").text
print(f"{ship['data-id']} {name:16} {price}")
print(soup.find("a")["href"])
That example needs beautifulsoup4 installed, so it has no run button. The
standard library does include an HTML parser, though, and for a simple job it is
enough:
from html.parser import HTMLParser
class LinkFinder(HTMLParser):
"""Collect every href in a document. No packages required."""
def __init__(self):
super().__init__()
self.links = []
def handle_starttag(self, tag, attrs):
if tag == "a":
for name, value in attrs:
if name == "href":
self.links.append(value)
finder = LinkFinder()
finder.feed("""
<p>See <a href="/python/">the course</a> and
<a href="https://rustyschool.com">the school</a>.</p>
""")
print(finder.links)
['/python/', 'https://rustyschool.com']
Selectors, the concise way
from bs4 import BeautifulSoup
soup = BeautifulSoup("<div class='card'><h2>Title</h2><p>Body</p></div>", "html.parser")
print(soup.select_one("div.card h2").text)
print([tag.name for tag in soup.select("div.card > *")])
print(soup.select("p")[0].get_text(strip=True))
| Selector | Matches |
|---|---|
div | every div |
.card | anything with class card |
#ships | the element with id ships |
div.card p | a p anywhere inside a div.card |
div > p | a p that is a direct child |
a[href^='/page'] | links whose href starts with /page |
A complete, polite scraper
import time
from pathlib import Path
import requests
from bs4 import BeautifulSoup
CACHE = Path("scrape-cache")
HEADERS = {"User-Agent": "python-school-example (contact: you@example.com)"}
def get_page(url: str, session: requests.Session, delay: float = 1.5) -> str:
"""Fetch a page, using a local cache so development costs the site nothing."""
CACHE.mkdir(exist_ok=True)
key = CACHE / (url.replace("/", "_").replace(":", "") + ".html")
if key.exists():
return key.read_text(encoding="utf-8")
response = session.get(url, timeout=10)
response.raise_for_status()
key.write_text(response.text, encoding="utf-8")
time.sleep(delay) # only sleep when we really hit the network
return response.text
def parse_ships(html: str) -> list[dict]:
"""Pull the ship records out of one page."""
soup = BeautifulSoup(html, "html.parser")
ships = []
for item in soup.select("li.ship"):
name = item.select_one(".name")
price = item.select_one(".price")
if not name or not price:
continue # the page changed; skip rather than crash
ships.append({
"id": item.get("data-id"),
"name": name.get_text(strip=True),
"price": price.get_text(strip=True),
})
return ships
def scrape_all(start_url: str, max_pages: int = 5) -> list[dict]:
"""Follow 'Next' links, politely, up to a hard limit."""
results = []
url = start_url
with requests.Session() as session:
session.headers.update(HEADERS)
for _ in range(max_pages):
html = get_page(url, session)
results.extend(parse_ships(html))
soup = BeautifulSoup(html, "html.parser")
next_link = soup.select_one("a.next")
if not next_link:
break
url = requests.compat.urljoin(url, next_link["href"])
return results
Note the hard limit on pages and the skip-rather-than-crash when a field is missing. Both exist because scrapers run unattended against pages that change without warning.
A scraper with no page limit that follows links is a program that can crawl an entire site, or loop forever between two pages that link to each other. Put a ceiling on anything that follows links it did not choose.
When the content is not in the HTML
Fetch a modern site and find an almost empty page: the content is loaded by JavaScript
after the page arrives, and requests does not run JavaScript. Three options,
in order of preference:
- Find the underlying API. Open your browser's developer tools, Network tab, and reload. The page is almost certainly fetching JSON from an endpoint you can call directly. This is faster and more stable than parsing HTML, and it is what experienced scrapers do first.
- Look for embedded data. Many pages ship their data inside a
<script type="application/json">tag. - Drive a real browser with Playwright or Selenium. Powerful, slow, fragile, and a much heavier commitment.
Tables, the easy case
import pandas as pd # pip install pandas lxml
tables = pd.read_html("https://en.wikipedia.org/wiki/List_of_programming_languages")
print(len(tables), "tables found")
print(tables[0].head())
If the data you want is already in an HTML <table>, one pandas call
turns every table on the page into a dataframe. It is worth checking for this before
writing any parsing code at all.
Extract structured data
Parse this fragment with the standard library only and produce a list of dictionaries.
<div class="book"><span class="t">Dune</span><span class="y">1965</span></div>
<div class="book"><span class="t">Neuromancer</span><span class="y">1984</span></div>Reveal solution
from html.parser import HTMLParser
class BookParser(HTMLParser):
"""Collect book titles and years without any third-party packages."""
def __init__(self):
super().__init__()
self.books = []
self.current = {}
self.field = None
def handle_starttag(self, tag, attrs):
classes = dict(attrs).get("class", "")
if tag == "div" and "book" in classes:
self.current = {}
elif tag == "span" and classes in ("t", "y"):
self.field = "title" if classes == "t" else "year"
def handle_data(self, data):
if self.field:
self.current[self.field] = data.strip()
self.field = None
def handle_endtag(self, tag):
if tag == "div" and self.current:
self.books.append(self.current)
self.current = {}
parser = BookParser()
parser.feed("""
<div class="book"><span class="t">Dune</span><span class="y">1965</span></div>
<div class="book"><span class="t">Neuromancer</span><span class="y">1984</span></div>
""")
for book in parser.books:
print(f"{book['title']:12} ({book['year']})")
Dune (1965)
Neuromancer (1984)This is a state machine: it remembers what it is currently inside. That is why BeautifulSoup exists, and why it is worth the install for anything beyond a simple document.
Would you scrape it?
For each, decide: scrape, use an API, or do not touch it.
- Product prices from a shop that has a public API.
- Your own posts from a forum you belong to.
- Email addresses from a member directory.
- Public weather data from a government site.
- Every article from a news site, to train a model.
Reveal solution
- Use the API. Faster, allowed, and it will not break when they change their CSS.
- Fine, and check whether there is an export feature first. Your own data is the easiest case there is.
- Do not. Harvesting personal contact details is a data protection problem in most jurisdictions and a spam problem everywhere.
- Usually fine, and often explicitly encouraged. Check for a bulk download; government sites frequently publish the whole dataset.
- Careful. Copyright applies to the articles, terms of service usually forbid it, and this is the subject of active litigation. At minimum, read the terms and prefer licensed datasets.
Make a scraper survive a redesign
Your scraper breaks every time the site changes. List four things that make one more durable.
Reveal solution
- Select on meaning, not on layout.
[data-product-id]or a semantic class survives a redesign;div > div > div:nth-child(3)does not. - Fail loudly on structure, quietly on content. If zero items parse, raise: the page has changed. If one item is missing a field, log it and continue.
- Cache raw HTML. When parsing breaks you can then debug against the exact page that failed, without hitting the site again.
- Write a test with a saved page. A stored HTML fixture plus an expected result turns 'it broke' into a failing test that shows you where.
And the honest one: scrapers rot. Anything built on someone else's HTML is borrowed time, which is the strongest argument for checking one more time whether an API exists.