Mathew K Analytics

Lesson 10 · Python Beginner Course

Web Scraping & APIs for Beginners: requests + BeautifulSoup (Beginner Python Course, Part 10)

In this lesson you'll be able to: Explain how the web works: requests, responses, status codes, headers and JSON Run your own tiny web server on your…

⬇ Download notebook

What you'll learn

Data

No separate download needed — the notebook creates or downloads everything it uses.

📓 Full notebook

Download .ipynb

Python and the Web: HTTP, APIs and Web Scraping#

In this lesson you'll be able to:

  • Explain how the web works: requests, responses, status codes, headers and JSON
  • Run your own tiny web server on your computer with http.server and a background thread
  • Fetch pages and call JSON APIs with requests, using params, headers, POST and timeouts
  • Handle network problems calmly with status checks and try / except
  • Pull data out of HTML with BeautifulSoup: find, find_all, select and tables
  • Capstone: build a price tracker that scrapes a shop and reports price drops

Warm-Up#

Two quick reminders from earlier lessons: dictionaries and JSON.

import json

order = {"drink": "flat white", "size": "large", "price": 3.40, "extras": ["oat milk"]}
text = json.dumps(order)
print(type(text), text)

back = json.loads(text)
print(back["drink"], "with", back["extras"][0])
<class 'str'> {"drink": "flat white", "size": "large", "price": 3.4, "extras": ["oat milk"]}
flat white with oat milk
menu = [{"item": "Croissant", "price": 2.20}, {"item": "Flat white", "price": 3.40}]
for row in menu:
    print(f"{row['item']:<12}${row['price']:.2f}")
print("JSON is just text, so it travels well:", json.dumps(menu)[:40], "...")
Croissant   $2.20
Flat white  $3.40
JSON is just text, so it travels well: [{"item": "Croissant", "price": 2.2}, {" ...

How the Web Works#

Your browser is a customer at a cafe counter: it makes a request, the server hands back a response.

Every response has a status code, some headers (labels) and a body (the actual content).

request_text = '''GET /menu.html HTTP/1.1
Host: cornercafe.test
User-Agent: MyBrowser/1.0
Accept: text/html'''
print(request_text)
GET /menu.html HTTP/1.1
Host: cornercafe.test
User-Agent: MyBrowser/1.0
Accept: text/html
from urllib.parse import urlparse

url = urlparse("https://cornercafe.test:8080/api/cafes?city=Leeds&limit=3")
print("scheme:", url.scheme)
print("host:  ", url.hostname)
print("port:  ", url.port)
print("path:  ", url.path)
print("query: ", url.query)
scheme: https
host:   cornercafe.test
port:   8080
path:   /api/cafes
query:  city=Leeds&limit=3
from urllib.parse import urlencode
print("/api/cafes?" + urlencode({"city": "York", "open_late": "yes"}))
print("/search?" + urlencode({"q": "flat white & cake"}))
/api/cafes?city=York&open_late=yes
/search?q=flat+white+%26+cake
from http import HTTPStatus
for code in [200, 201, 301, 400, 401, 404, 500]:
    print(code, HTTPStatus(code).phrase)
200 OK
201 Created
301 Moved Permanently
400 Bad Request
401 Unauthorized
404 Not Found
500 Internal Server Error
def status_family(code):
    families = {2: "success", 3: "redirect",
                4: "your mistake (client error)", 5: "their mistake (server error)"}
    return families.get(code // 100, "unknown")
for code in [200, 302, 404, 503]:
    print(code, "->", status_family(code))
200 -> success
302 -> redirect
404 -> your mistake (client error)
503 -> their mistake (server error)

Quick check#

A server answers 403. Which family is that, and what does it mean?

print(403, "->", status_family(403))
print("Meaning:", HTTPStatus(403).phrase)
403 -> your mistake (client error)
Meaning: Forbidden

Build Your Own Tiny Website#

No internet needed: we'll run a real web server on our own computer, at the address 127.0.0.1.

Pro tip: 127.0.0.1 (also called localhost) means "this machine". Nobody else can reach it, so it's a safe playground.

from pathlib import Path

SITE = Path("lesson_files/site")
SITE.mkdir(parents=True, exist_ok=True)

(SITE / "index.html").write_text('''<html><head><title>Corner Cafe</title></head>
<body>
  <h1>Corner Cafe</h1>
  <p class="tagline">Good coffee, slow mornings.</p>
  <a href="menu.html">Menu</a> | <a href="about.html">About us</a> | <a href="jobs.html">Jobs</a>
</body></html>''', encoding="utf-8")
print(sorted(p.name for p in SITE.iterdir()))
['index.html']
menu_html = '''<html><head><title>Menu</title></head><body>
<h1>Our Menu</h1>
<table id="menu">
  <tr><th>Item</th><th>Type</th><th>Price</th></tr>
  <tr class="drink"><td>Flat white</td><td>drink</td><td>$3.40</td></tr>
  <tr class="drink"><td>Chai latte</td><td>drink</td><td>$3.60</td></tr>
  <tr class="food"><td>Cheese toastie</td><td>food</td><td>$5.20</td></tr>
  <tr class="food special"><td>Banana bread</td><td>food</td><td>$2.80</td></tr>
</table>
<p>Back to <a href="index.html">home</a></p>
</body></html>'''
(SITE / "menu.html").write_text(menu_html, encoding="utf-8")
print(len(menu_html), "characters written to menu.html")
503 characters written to menu.html
(SITE / "about.html").write_text('''<html><head><title>About us</title></head><body>
<h1>About us</h1>
<p id="story">Two friends, one very old espresso machine.</p>
<img src="team.jpg" alt="The cafe team">
</body></html>''', encoding="utf-8")

(SITE / "robots.txt").write_text("User-agent: *\nDisallow: /private/\nCrawl-delay: 1\n")
print(sorted(p.name for p in SITE.iterdir()))
['about.html', 'index.html', 'menu.html', 'robots.txt']
CAFES = [
    {"id": 1, "name": "Bean There", "city": "Leeds", "rating": 4.6, "wifi": True},
    {"id": 2, "name": "Grind House", "city": "York", "rating": 4.1, "wifi": False},
    {"id": 3, "name": "The Daily Cup", "city": "Leeds", "rating": 3.9, "wifi": True},
    {"id": 4, "name": "Steam and Leaf", "city": "Bristol", "rating": 4.8, "wifi": True},
    {"id": 5, "name": "Mug Life", "city": "York", "rating": 4.4, "wifi": True},
]
WEATHER = {"leeds": {"temp_c": 12, "sky": "drizzle"},
           "york": {"temp_c": 10, "sky": "cloudy"},
           "bristol": {"temp_c": 14, "sky": "sunny spells"}}
print(len(CAFES), "cafes and", len(WEATHER), "cities with weather")
5 cafes and 3 cities with weather

An API route is just a function: it gets the question (the query) and returns a status code plus some data.

def cafes_route(query, headers):
    results = CAFES
    if "city" in query:
        results = [c for c in results if c["city"].lower() == query["city"].lower()]
    if "min_rating" in query:
        results = [c for c in results if c["rating"] >= float(query["min_rating"])]
    return 200, {"count": len(results), "cafes": results}

def weather_route(query, headers):
    city = query.get("city", "").lower()
    if city not in WEATHER:
        return 404, {"error": f"No weather for '{city}'"}
    return 200, {"city": city.title(), **WEATHER[city]}

API_ROUTES = {"/api/cafes": cafes_route, "/api/weather": weather_route}
POST_ROUTES = {}
print("GET routes:", list(API_ROUTES))
GET routes: ['/api/cafes', '/api/weather']
print(cafes_route({"city": "york"}, {}))
print(weather_route({"city": "Mars"}, {}))
(200, {'count': 2, 'cafes': [{'id': 2, 'name': 'Grind House', 'city': 'York', 'rating': 4.1, 'wifi': False}, {'id': 5, 'name': 'Mug Life', 'city': 'York', 'rating': 4.4, 'wifi': True}]})
(404, {'error': "No weather for 'mars'"})

Now the handler: a class that http.server calls for every request. It serves files from SITE, and answers /api/... paths with JSON.

from http.server import SimpleHTTPRequestHandler

class QuietHandler(SimpleHTTPRequestHandler):
    extensions_map = {".html": "text/html; charset=utf-8", ".txt": "text/plain; charset=utf-8"}

    def __init__(self, *args, **kwargs):
        super().__init__(*args, directory=str(SITE), **kwargs)

    def log_message(self, *args):
        pass  # don't print a line for every request

    def send_json(self, data, status=200):
        body = json.dumps(data).encode("utf-8")
        try:
            self.send_response(status)
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
        except (BrokenPipeError, ConnectionResetError):
            pass  # the visitor gave up waiting and left

print("QuietHandler is ready")
QuietHandler is ready
from urllib.parse import parse_qs

class LessonHandler(QuietHandler):
    def do_GET(self):
        url = urlparse(self.path)
        route = API_ROUTES.get(url.path)
        if route is None:
            return super().do_GET()  # not an API path: serve a file
        query = {key: values[0] for key, values in parse_qs(url.query).items()}
        try:
            status, data = route(query, self.headers)
        except ValueError:
            status, data = 400, {"error": "Bad value in your query"}
        self.send_json(data, status)

    def do_POST(self):
        size = int(self.headers.get("Content-Length", 0))
        body = json.loads(self.rfile.read(size) or b"{}")
        route = POST_ROUTES.get(urlparse(self.path).path)
        status, data = route(body, self.headers) if route else (404, {"error": "Not found"})
        self.send_json(data, status)

print("LessonHandler can answer GET and POST")
LessonHandler can answer GET and POST
import threading
from http.server import ThreadingHTTPServer
server = ThreadingHTTPServer(("127.0.0.1", 0), LessonHandler)  # port 0 = any free port
port = server.server_address[1]
BASE = f"http://127.0.0.1:{port}"

thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
print("Server running in the background:", thread.is_alive())
Server running in the background: True

Pro tip: the server runs in a background thread, so the notebook stays free to send it requests. We'll shut it down at the very end.

Your First Request with requests#

requests.get(url) is like typing an address in your browser, except Python reads the reply. Use r.text for pages you read as text, and r.content for raw bytes such as images or zip files.

import requests
r = requests.get(BASE + "/index.html")
print(r.status_code, r.reason)
print("ok?", r.ok)
print(r.text[:90])
200 OK
ok? True
<html><head><title>Corner Cafe</title></head>
<body>
  <h1>Corner Cafe</h1>
  <p class="ta
print(r.headers["Content-Type"])
print(r.headers["content-type"])   # header names ignore case
print("Body size:", len(r.content), "bytes")
text/html; charset=utf-8
text/html; charset=utf-8
Body size: 241 bytes
missing = requests.get(BASE + "/pancakes.html")
print(missing.status_code, missing.reason)
print("ok?", missing.ok)
404 File not found
ok? False
for page in ["/index.html", "/menu.html", "/about.html", "/jobs.html"]:
    r = requests.get(BASE + page)
    print(f"{page:<13} {r.status_code}  {status_family(r.status_code)}")
/index.html   200  success
/menu.html    200  success
/about.html   200  success
/jobs.html    404  your mistake (client error)

Talking to a JSON API#

A web page is written for people. An API is written for programs: it answers in tidy JSON.

r = requests.get(BASE + "/api/cafes")
print(r.status_code, r.headers["Content-Type"])
data = r.json()          # JSON text -> Python dict
print(type(data), "with keys", list(data))
print("Cafes found:", data["count"])
200 application/json; charset=utf-8
<class 'dict'> with keys ['count', 'cafes']
Cafes found: 5
for cafe in data["cafes"]:
    wifi = "wifi" if cafe["wifi"] else "no wifi"
    print(f"{cafe['name']:<16}{cafe['city']:<9}{cafe['rating']:>4}  {wifi}")
Bean There      Leeds     4.6  wifi
Grind House     York      4.1  no wifi
The Daily Cup   Leeds     3.9  wifi
Steam and Leaf  Bristol   4.8  wifi
Mug Life        York      4.4  wifi

Use params= for the bit after the ?. requests builds and encodes the URL for you.

r = requests.get(BASE + "/api/cafes", params={"city": "Leeds"})
print(r.url.removeprefix(BASE))
for cafe in r.json()["cafes"]:
    print(" ", cafe["name"], cafe["rating"])
/api/cafes?city=Leeds
  Bean There 4.6
  The Daily Cup 3.9
r = requests.get(BASE + "/api/cafes", params={"city": "York", "min_rating": 4.2})
print(r.url.removeprefix(BASE))
print([cafe["name"] for cafe in r.json()["cafes"]])
/api/cafes?city=York&min_rating=4.2
['Mug Life']
while True:
    city = input("City for weather (blank to stop): ").strip()
    if not city:
        break
    r = requests.get(BASE + "/api/weather", params={"city": city})
    if r.status_code == 200:
        w = r.json()
        print(f"  {w['city']}: {w['temp_c']}C and {w['sky']}")
    else:
        print(f"  {r.status_code}: {r.json()['error']}")
City for weather (blank to stop): Leeds
  Leeds: 12C and drizzle
City for weather (blank to stop): Atlantis
  404: No weather for 'atlantis'
City for weather (blank to stop): bristol
  Bristol: 14C and sunny spells
City for weather (blank to stop): 

Common mistake: calling .json() on a page that isn't JSON#

menu_data = requests.get(BASE + "/menu.html").json()
---------------------------------------------------------------------------
JSONDecodeError                           Traceback (most recent call last)
File ~/.local/lib/python3.13/site-packages/requests/models.py:1116, in Response.json(self, **kwargs)
   1115 try:
-> 1116     return complexjson.loads(self.text, **kwargs)
   1117 except JSONDecodeError as e:
   1118     # Catch JSON-related errors and raise as requests.JSONDecodeError
   1119     # This aliases json.JSONDecodeError and simplejson.JSONDecodeError

File /usr/lib/python3.13/json/__init__.py:352, in loads(s, cls, object_hook, parse_float, parse_int, parse_constant, object_pairs_hook, **kw)
    349 if (cls is None and object_hook is None and
    350         parse_int is None and parse_float is None and
    351         parse_constant is None and object_pairs_hook is None and not kw):
--> 352     return _default_decoder.decode(s)
    353 if cls is None:

File /usr/lib/python3.13/json/decoder.py:345, in JSONDecoder.decode(self, s, _w)
    341 """Return the Python representation of ``s`` (a ``str`` instance
    342 containing a JSON document).
    343 
    344 """
--> 345 obj, end = self.raw_decode(s, idx=_w(s, 0).end())
    346 end = _w(s, end).end()

File /usr/lib/python3.13/json/decoder.py:363, in JSONDecoder.raw_decode(self, s, idx)
    362 except StopIteration as err:
--> 363     raise JSONDecodeError("Expecting value", s, err.value) from None
    364 return obj, end

JSONDecodeError: Expecting value: line 1 column 1 (char 0)

During handling of the above exception, another exception occurred:

JSONDecodeError                           Traceback (most recent call last)
Cell In[28], line 1
----> 1 menu_data = requests.get(BASE + "/menu.html").json()

File ~/.local/lib/python3.13/site-packages/requests/models.py:1120, in Response.json(self, **kwargs)
   1116     return complexjson.loads(self.text, **kwargs)
   1117 except JSONDecodeError as e:
   1118     # Catch JSON-related errors and raise as requests.JSONDecodeError
   1119     # This aliases json.JSONDecodeError and simplejson.JSONDecodeError
-> 1120     raise RequestsJSONDecodeError(e.msg, e.doc, e.pos)

JSONDecodeError: Expecting value: line 1 column 1 (char 0)
r = requests.get(BASE + "/menu.html")
if "application/json" in r.headers["Content-Type"]:
    print(r.json())
else:
    print("That's", r.headers["Content-Type"], "so read r.text instead")
That's text/html; charset=utf-8 so read r.text instead

Your turn#

  1. Ask the API for cafes in Bristol and print just their names.
  2. Print the average rating of all cafes returned by /api/cafes.

When Things Go Wrong#

Servers go down, pages move and replies are slow. Good code expects it.

Pro tip: always pass timeout=. Without it, requests can wait forever for a server that never answers.

import time

def slow_route(query, headers):
    time.sleep(2)  # pretend the server is very busy
    return 200, {"message": "Sorry for the wait!"}

API_ROUTES["/api/slow"] = slow_route
print("Routes now:", list(API_ROUTES))
Routes now: ['/api/cafes', '/api/weather', '/api/slow']
try:
    r = requests.get(BASE + "/api/slow", timeout=0.5)
    print(r.json())
except requests.exceptions.Timeout:
    print("Gave up after 0.5 seconds. The server is too slow.")
Gave up after 0.5 seconds. The server is too slow.
r = requests.get(BASE + "/api/weather", params={"city": "Atlantis"})
try:
    r.raise_for_status()      # turns 4xx and 5xx into an exception
    print("All good")
except requests.exceptions.HTTPError as err:
    print("HTTP problem:", err.response.status_code, err.response.reason)
HTTP problem: 404 Not Found
try:
    requests.get("http://127.0.0.1:1/", timeout=2)   # nothing listens on port 1
except requests.exceptions.ConnectionError:
    print("Could not connect. Is the server running?")
Could not connect. Is the server running?
def safe_get(url, **kwargs):
    try:
        r = requests.get(url, timeout=3, **kwargs)
        r.raise_for_status()
        return r
    except requests.exceptions.RequestException as err:
        print(f"  Could not fetch {url.removeprefix(BASE)}: {type(err).__name__}")
        return None

for path in ["/menu.html", "/pancakes.html", "/api/weather?city=york"]:
    r = safe_get(BASE + path)
    print(path, "->", "got it" if r else "skipped")
/menu.html -> got it
  Could not fetch /pancakes.html: HTTPError
/pancakes.html -> skipped
/api/weather?city=york -> got it

Common mistake: forgetting the http:// part#

r = requests.get("127.0.0.1/menu.html")
---------------------------------------------------------------------------
MissingSchema                             Traceback (most recent call last)
Cell In[35], line 1
----> 1 r = requests.get("127.0.0.1/menu.html")

File ~/.local/lib/python3.13/site-packages/requests/api.py:87, in get(url, params, **kwargs)
     74 def get(
     75     url: _t.UriType, params: _t.ParamsType = None, **kwargs: Unpack[_t.GetKwargs]
     76 ) -> Response:
     77     r"""Sends a GET request.
     78 
     79     :param url: URL for the new :class:`Request` object.
   (...)     84     :rtype: requests.Response
     85     """
---> 87     return request("get", url, params=params, **kwargs)

File ~/.local/lib/python3.13/site-packages/requests/api.py:71, in request(method, url, **kwargs)
     67 # By using the 'with' statement we are sure the session is closed, thus we
     68 # avoid leaving sockets open which can trigger a ResourceWarning in some
     69 # cases, and look like a memory leak in others.
     70 with sessions.Session() as session:
---> 71     return session.request(method=method, url=url, **kwargs)

File ~/.local/lib/python3.13/site-packages/requests/sessions.py:635, in Session.request(self, method, url, params, data, headers, cookies, files, auth, timeout, allow_redirects, proxies, hooks, stream, verify, cert, json)
    622 # Create the Request.
    623 req = Request(
    624     method=method.upper(),
    625     url=url,
   (...)    633     hooks=hooks,
    634 )
--> 635 prep = self.prepare_request(req)
    637 assert _is_prepared(prep)
    639 proxies = proxies or {}

File ~/.local/lib/python3.13/site-packages/requests/sessions.py:541, in Session.prepare_request(self, request)
    538     auth = get_netrc_auth(url)
    540 p = PreparedRequest()
--> 541 p.prepare(
    542     method=method.upper(),
    543     url=url,
    544     files=request.files,
    545     data=request.data,
    546     json=request.json,
    547     headers=merge_setting(
    548         request.headers, self.headers, dict_class=CaseInsensitiveDict
    549     ),
    550     params=merge_setting(request.params, self.params),
    551     auth=merge_setting(auth, self.auth),
    552     cookies=merged_cookies,
    553     hooks=merge_hooks(request.hooks, self.hooks),
    554 )
    555 return p

File ~/.local/lib/python3.13/site-packages/requests/models.py:439, in PreparedRequest.prepare(self, method, url, headers, files, data, params, auth, cookies, hooks, json)
    437 url = cast("_t.UriType", url)
    438 self.prepare_method(method)
--> 439 self.prepare_url(url, params)
    440 self.prepare_headers(headers)
    441 self.prepare_cookies(cookies)

File ~/.local/lib/python3.13/site-packages/requests/models.py:514, in PreparedRequest.prepare_url(self, url, params)
    511     raise InvalidURL(*e.args)
    513 if not scheme:
--> 514     raise MissingSchema(
    515         f"Invalid URL {url!r}: No scheme supplied. "
    516         f"Perhaps you meant https://{url}?"
    517     )
    519 if not host:
    520     raise InvalidURL(f"Invalid URL {url!r}: No host supplied")

MissingSchema: Invalid URL '127.0.0.1/menu.html': No scheme supplied. Perhaps you meant https://127.0.0.1/menu.html?
print(BASE.startswith("http://"))
r = requests.get(BASE + "/menu.html")
print(r.status_code, "Menu page fetched")
True
200 Menu page fetched

Common mistake: assuming every request worked#

r = requests.get(BASE + "/api/weather", params={"city": "Atlantis"})
print(r.json()["temp_c"], "degrees")
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
Cell In[37], line 2
      1 r = requests.get(BASE + "/api/weather", params={"city": "Atlantis"})
----> 2 print(r.json()["temp_c"], "degrees")

KeyError: 'temp_c'
r = requests.get(BASE + "/api/weather", params={"city": "Atlantis"})
if r.ok:
    print(r.json()["temp_c"], "degrees")
else:
    print("No luck:", r.status_code, r.json()["error"])
No luck: 404 No weather for 'atlantis'

Sending Data: POST and Headers#

GET is reading the menu. POST is handing over your order.

ORDERS = []

def new_order_route(body, headers):
    if "drink" not in body:
        return 400, {"error": "Every order needs a drink"}
    order = {"id": len(ORDERS) + 1, "drink": body["drink"], "name": body.get("name", "guest")}
    ORDERS.append(order)
    return 201, order

POST_ROUTES["/api/orders"] = new_order_route
print("POST routes:", list(POST_ROUTES))
POST routes: ['/api/orders']
r = requests.post(BASE + "/api/orders", json={"drink": "flat white", "name": "Ana"}, timeout=3)
print(r.status_code, r.reason)
print(r.json())
201 Created
{'id': 1, 'drink': 'flat white', 'name': 'Ana'}
new_orders = [{"drink": "chai latte", "name": "Ben"},
              {"name": "Cleo"},
              {"drink": "mocha"}]
for item in new_orders:
    r = requests.post(BASE + "/api/orders", json=item, timeout=3)
    print(r.status_code, r.json())
201 {'id': 2, 'drink': 'chai latte', 'name': 'Ben'}
400 {'error': 'Every order needs a drink'}
201 {'id': 3, 'drink': 'mocha', 'name': 'guest'}
def orders_route(query, headers):
    return 200, {"orders": ORDERS}

API_ROUTES["/api/orders"] = orders_route
for order in requests.get(BASE + "/api/orders", timeout=3).json()["orders"]:
    print(f"#{order['id']}  {order['name']:<6}{order['drink']}")
#1  Ana   flat white
#2  Ben   chai latte
#3  guest mocha

Headers are labels on the request. Servers often read User-Agent (who is asking) and API keys.

def whoami_route(query, headers):
    return 200, {"user_agent": headers.get("User-Agent")}

API_ROUTES["/api/whoami"] = whoami_route
r = requests.get(BASE + "/api/whoami", timeout=3)
print("Default:", r.json()["user_agent"].split("/")[0])

my_headers = {"User-Agent": "CornerCafeBot/1.0 (learning project)"}
r = requests.get(BASE + "/api/whoami", headers=my_headers, timeout=3)
print("Custom: ", r.json()["user_agent"])
Default: python-requests
Custom:  CornerCafeBot/1.0 (learning project)
def secret_route(query, headers):
    if headers.get("X-API-Key") != "latte-123":
        return 401, {"error": "Missing or wrong API key"}
    return 200, {"recipe": "honey, cinnamon and a pinch of salt"}

API_ROUTES["/api/secret-syrup"] = secret_route
r = requests.get(BASE + "/api/secret-syrup", timeout=3)
print(r.status_code, r.json())
401 {'error': 'Missing or wrong API key'}
r = requests.get(BASE + "/api/secret-syrup", headers={"X-API-Key": "latte-123"}, timeout=3)
print(r.status_code, r.json())
200 {'recipe': 'honey, cinnamon and a pinch of salt'}

Pro tip: never paste a real API key into a notebook you share. Keep it in an environment variable and read it with os.environ.

Your turn#

  1. POST an order for yourself, then GET /api/orders and print how many orders there are now.
  2. Call /api/secret-syrup with a wrong key and print the error message the server sends back.

Quick check#

Which status code did the server send back when an order was created, and what's its name?

r = requests.post(BASE + "/api/orders", json={"drink": "espresso"}, timeout=3)
print(r.status_code, HTTPStatus(r.status_code).phrase)
201 Created

Mini-Project: The Cafe Finder#

Ask for a city and a minimum rating, call the API, show the matches and the weather.

def find_cafes(city, min_rating=0):
    r = safe_get(BASE + "/api/cafes", params={"city": city, "min_rating": min_rating})
    return r.json()["cafes"] if r else []

def get_weather(city):
    r = safe_get(BASE + "/api/weather", params={"city": city})
    return r.json() if r else None

print([c["name"] for c in find_cafes("Leeds", 4)], get_weather("York"))
['Bean There'] {'city': 'York', 'temp_c': 10, 'sky': 'cloudy'}
def ask_rating():
    while True:
        answer = input("Minimum rating (0-5)? ")
        try:
            return float(answer)
        except ValueError:
            print(f"  '{answer}' isn't a number, try something like 4.2")

print("ask_rating is ready")
ask_rating is ready
def cafe_finder():
    while True:
        city = input("Which city? ").strip()
        rating = ask_rating()
        cafes = sorted(find_cafes(city, rating), key=lambda c: c["rating"], reverse=True)
        print(f"  {len(cafes)} cafe(s) in {city.title()} rated {rating}+")
        for cafe in cafes:
            print(f"   * {cafe['name']:<15}{cafe['rating']}")
        weather = get_weather(city)
        if weather:
            print(f"  Weather: {weather['temp_c']}C, {weather['sky']}")
        if input("Search again? (y/n) ").strip().lower() != "y":
            break
    print("Enjoy your coffee!")

cafe_finder()
Which city? York
Minimum rating (0-5)? four
  'four' isn't a number, try something like 4.2
Minimum rating (0-5)? 4.2
  1 cafe(s) in York rated 4.2+
   * Mug Life       4.4
  Weather: 10C, cloudy
Search again? (y/n) y
Which city? Leeds
Minimum rating (0-5)? 4
  1 cafe(s) in Leeds rated 4.0+
   * Bean There     4.6
  Weather: 12C, drizzle
Search again? (y/n) n
Enjoy your coffee!

Reading HTML with BeautifulSoup#

HTML is a set of nested boxes (tags). BeautifulSoup lets you open the box you want.

from bs4 import BeautifulSoup

playlist_html = '''<html><head><title>Sunday Playlist</title></head><body>
<h1>Sunday Playlist</h1>
<p class="intro">Slow songs for a slow morning.</p>
<ul id="songs">
  <li class="song" data-mins="4">River Song</li>
  <li class="song" data-mins="3">Golden Hour</li>
  <li class="song fav" data-mins="5">Paper Boats</li>
</ul>
<a href="/playlists/rainy-day" title="Next playlist">Rainy Day</a>
</body></html>'''

soup = BeautifulSoup(playlist_html, "html.parser")
print(soup.title.text)
print(soup.h1.text)
Sunday Playlist
Sunday Playlist
intro = soup.find("p", class_="intro")
print(intro)
print(intro.text)
<p class="intro">Slow songs for a slow morning.</p>
Slow songs for a slow morning.
songs = soup.find_all("li")
print(len(songs), "songs")
for number, song in enumerate(songs, start=1):
    print(number, song.text)
3 songs
1 River Song
2 Golden Hour
3 Paper Boats
link = soup.find("a")
print(link["href"])
print(link.get("title"))
print(link.attrs)
/playlists/rainy-day
Next playlist
{'href': '/playlists/rainy-day', 'title': 'Next playlist'}
total = 0
for song in soup.find_all("li", class_="song"):
    minutes = int(song["data-mins"])
    total += minutes
    print(f"{song.text:<12}{minutes} min")
print("Playlist length:", total, "minutes")
River Song  4 min
Golden Hour 3 min
Paper Boats 5 min
Playlist length: 12 minutes

Common mistake: find found nothing#

find returns None when there's no match, and None has no .text.

print(soup.find("p", class_="outro").text)
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[55], line 1
----> 1 print(soup.find("p", class_="outro").text)

AttributeError: 'NoneType' object has no attribute 'text'
outro = soup.find("p", class_="outro")
if outro is None:
    print("No outro on this page, skipping it")
else:
    print(outro.text)
No outro on this page, skipping it

CSS Selectors and Tables#

select uses the same patterns as CSS: tag, .class, #id, and spaces for "inside".

print([li.text for li in soup.select("li.fav")])
print(soup.select_one("#songs li").text)
print(len(soup.select("ul#songs > li")), "songs inside the list")
['Paper Boats']
River Song
3 songs inside the list
r = requests.get(BASE + "/menu.html", timeout=3)
menu_soup = BeautifulSoup(r.text, "html.parser")
print(menu_soup.title.text)
print(menu_soup.select_one("tr.drink td").text)
Menu
Flat white
table = menu_soup.find("table", id="menu")
rows = []
for tr in table.find_all("tr"):
    cells = [cell.get_text(strip=True) for cell in tr.find_all(["th", "td"])]
    rows.append(cells)
for row in rows:
    print(row)
['Item', 'Type', 'Price']
['Flat white', 'drink', '$3.40']
['Chai latte', 'drink', '$3.60']
['Cheese toastie', 'food', '$5.20']
['Banana bread', 'food', '$2.80']
header, *body = rows
menu_items = [dict(zip(header, row)) for row in body]
for item in menu_items:
    item["Price"] = float(item["Price"].replace("$", ""))
print(menu_items[0])
print("Cheapest:", min(menu_items, key=lambda i: i["Price"])["Item"])
{'Item': 'Flat white', 'Type': 'drink', 'Price': 3.4}
Cheapest: Banana bread

Your turn#

  1. Use select to print only the drink names and their prices.
  2. Work out the average price of the food items in menu_items.

A scraper often starts on one page, collects its links, then visits each one.

home = BeautifulSoup(requests.get(BASE + "/index.html", timeout=3).text, "html.parser")
print(home.select_one(".tagline").text)
links = [a["href"] for a in home.find_all("a")]
print(links)
Good coffee, slow mornings.
['menu.html', 'about.html', 'jobs.html']
from urllib.parse import urljoin
print(urljoin(BASE + "/index.html", "menu.html").removeprefix(BASE))
print(urljoin(BASE + "/shop/index.html", "/about.html").removeprefix(BASE))
/menu.html
/about.html
for href in links:
    r = safe_get(urljoin(BASE + "/index.html", href))
    if r:
        page = BeautifulSoup(r.text, "html.parser")
        print(f"{href:<12} title: {page.title.text}")
menu.html    title: Menu
about.html   title: About us
  Could not fetch /jobs.html: HTTPError

Being a Polite Scraper#

Scraping is like visiting a shop: read the rules on the door, don't block the aisles, and say who you are.

  • Use an official API if there is one.
  • Read the site's terms and its robots.txt.
  • Slow down: wait between requests and don't fetch the same page twice.
  • Send an honest User-Agent, and never scrape personal data.
print(requests.get(BASE + "/robots.txt", timeout=3).text)
User-agent: *
Disallow: /private/
Crawl-delay: 1

from urllib.robotparser import RobotFileParser
robots = RobotFileParser()
robots.set_url(BASE + "/robots.txt")
robots.read()
for path in ["/menu.html", "/private/staff-rota.html"]:
    print(f"{path:<26} allowed: {robots.can_fetch('CornerCafeBot', BASE + path)}")
print("Crawl delay:", robots.crawl_delay("CornerCafeBot"), "second(s)")
/menu.html                 allowed: True
/private/staff-rota.html   allowed: False
Crawl delay: 1 second(s)
page_cache = {}

def polite_get(url, delay=0.2):
    if url in page_cache:
        return page_cache[url]
    if not robots.can_fetch("CornerCafeBot", url):
        print("  robots.txt says no:", url.removeprefix(BASE))
        return None
    time.sleep(delay)  # be kind to the server
    r = safe_get(url, headers={"User-Agent": "CornerCafeBot/1.0"})
    page_cache[url] = r
    return r

print("polite_get is ready")
polite_get is ready
start = time.perf_counter()
for path in ["/menu.html", "/about.html", "/menu.html", "/private/staff-rota.html"]:
    r = polite_get(BASE + path)
    print(path, "->", r.status_code if r else "not fetched")
print(f"Pages in cache: {len(page_cache)}, took about {time.perf_counter() - start:.1f}s")
/menu.html -> 200
/about.html -> 200
/menu.html -> 200
  robots.txt says no: /private/staff-rota.html
/private/staff-rota.html -> not fetched
Pages in cache: 2, took about 0.4s

Quick check#

Why did the second /menu.html request come back instantly?

print("Because it came from page_cache, so no new request was sent.")
print("Cached pages:", [url.removeprefix(BASE) for url in page_cache])
Because it came from page_cache, so no new request was sent.
Cached pages: ['/menu.html', '/about.html']

Capstone: The Coffee Gear Price Tracker#

A fake shop, Brew Gear, has product pages. Its API also tells us yesterday's prices. We'll scrape today's prices, compare, and report the price drops.

TODAY = {
    "pour-over":  ("Ceramic pour-over dripper", 18.50, True),
    "grinder":    ("Burr coffee grinder", 64.99, True),
    "kettle":     ("Gooseneck kettle", 39.00, True),
    "jar":        ("Glass coffee jar", 12.75, True),
    "scale":      ("Digital coffee scale", 21.00, False),
    "travel-mug": ("Travel mug", 14.99, True),
}
YESTERDAY = {"pour-over": 22.00, "grinder": 79.99, "kettle": 39.00,
             "jar": 11.50, "scale": 24.50, "travel-mug": 14.99}
print(len(TODAY), "products in the shop")
6 products in the shop
def product_page(slug, name, price, in_stock):
    stock = "In stock" if in_stock else "Out of stock"
    return f'''<html><head><title>{name} | Brew Gear</title></head><body>
<div class="product" data-sku="{slug}">
  <h1 class="name">{name}</h1>
  <p>Now only <span class="price">${price:.2f}</span></p>
  <p class="stock">{stock}</p>
</div></body></html>'''

print(product_page("demo", "Demo mug", 9.5, True))
<html><head><title>Demo mug | Brew Gear</title></head><body>
<div class="product" data-sku="demo">
  <h1 class="name">Demo mug</h1>
  <p>Now only <span class="price">$9.50</span></p>
  <p class="stock">In stock</p>
</div></body></html>
SHOP = SITE / "shop"
SHOP.mkdir(exist_ok=True)
links = []
for slug, (name, price, in_stock) in TODAY.items():
    (SHOP / f"{slug}.html").write_text(product_page(slug, name, price, in_stock), encoding="utf-8")
    links.append(f'<li><a class="product-link" href="{slug}.html">{name}</a></li>')

(SHOP / "index.html").write_text("<html><head><title>Brew Gear</title></head><body><ul>"
                                 + "\n".join(links) + "</ul></body></html>", encoding="utf-8")
print(sorted(p.name for p in SHOP.iterdir()))
['grinder.html', 'index.html', 'jar.html', 'kettle.html', 'pour-over.html', 'scale.html', 'travel-mug.html']
def yesterday_route(query, headers):
    return 200, {"date": "2026-03-14", "currency": "GBP", "prices": YESTERDAY}

API_ROUTES["/api/prices/yesterday"] = yesterday_route
print(requests.get(BASE + "/api/prices/yesterday", timeout=3).json()["date"])
2026-03-14

Step 1: find every product link on the shop's front page. urljoin turns each short link into a full address.

SHOP_URL = BASE + "/shop/index.html"

def product_links():
    r = polite_get(SHOP_URL)
    shop_soup = BeautifulSoup(r.text, "html.parser")
    return [urljoin(SHOP_URL, a["href"]) for a in shop_soup.select("a.product-link")]

product_urls = product_links()
print(len(product_urls), "product pages, for example", product_urls[0].removeprefix(BASE))
6 product pages, for example /shop/pour-over.html

Step 2: scrape one product page into a dictionary.

def scrape_product(url):
    r = polite_get(url)
    if r is None:
        return None
    page = BeautifulSoup(r.text, "html.parser")
    box = page.select_one("div.product")
    return {
        "sku": box["data-sku"],
        "name": box.select_one(".name").get_text(strip=True),
        "price": float(box.select_one(".price").text.replace("$", "")),
        "in_stock": box.select_one(".stock").text == "In stock",
    }

print(scrape_product(product_urls[1]))
{'sku': 'grinder', 'name': 'Burr coffee grinder', 'price': 64.99, 'in_stock': True}

Step 3: scrape them all (politely), then fetch yesterday's prices from the API.

start = time.perf_counter()
today = [scrape_product(url) for url in product_urls]
print(f"Scraped {len(today)} products in about {time.perf_counter() - start:.1f}s")
old = safe_get(BASE + "/api/prices/yesterday").json()
yesterday = old["prices"]
print("Yesterday's prices from", old["date"], "for", len(yesterday), "products")
Scraped 6 products in about 1.0s
Yesterday's prices from 2026-03-14 for 6 products

Step 4: compare each product with yesterday. A negative change means the price went down, which is good news for us.

def compare(product, old_prices):
    before = old_prices.get(product["sku"])
    change = round(product["price"] - before, 2)
    percent = change / before * 100
    return {**product, "was": before, "change": change, "percent": round(percent, 1)}

changes = [compare(p, yesterday) for p in today]
print(changes[0])
{'sku': 'pour-over', 'name': 'Ceramic pour-over dripper', 'price': 18.5, 'in_stock': True, 'was': 22.0, 'change': -3.5, 'percent': -15.9}
drops = sorted([c for c in changes if c["change"] < 0], key=lambda c: c["percent"])
rises = [c for c in changes if c["change"] > 0]
same = [c for c in changes if c["change"] == 0]
print(f"{len(drops)} drop(s), {len(rises)} rise(s), {len(same)} unchanged")
3 drop(s), 1 rise(s), 2 unchanged

Step 5: the report, sorted so the biggest drops come first, plus a personal watchlist check.

def print_report():
    print("BREW GEAR PRICE TRACKER")
    print("=" * 67)
    print(f"{'Product':<28}{'Was':>8}{'Now':>8}{'Change':>10}  Trend Stock")
    for c in drops + rises + same:
        arrow = "DOWN" if c["change"] < 0 else "UP" if c["change"] > 0 else "-"
        stock = "yes" if c["in_stock"] else "SOLD OUT"
        was, now = f"${c['was']:.2f}", f"${c['price']:.2f}"
        print(f"{c['name']:<28}{was:>8}{now:>8}{c['percent']:>+9.1f}%  {arrow:<5}{stock}")
    print("=" * 67)

print_report()
BREW GEAR PRICE TRACKER
===================================================================
Product                          Was     Now    Change  Trend Stock
Burr coffee grinder           $79.99  $64.99    -18.8%  DOWN yes
Ceramic pour-over dripper     $22.00  $18.50    -15.9%  DOWN yes
Digital coffee scale          $24.50  $21.00    -14.3%  DOWN SOLD OUT
Glass coffee jar              $11.50  $12.75    +10.9%  UP   yes
Gooseneck kettle              $39.00  $39.00     +0.0%  -    yes
Travel mug                    $14.99  $14.99     +0.0%  -    yes
===================================================================
best = drops[0]
saving = -best["change"]
print(f"Biggest drop: {best['name']}, now ${best['price']:.2f} (save ${saving:.2f})")
buyable = [d["name"] for d in drops if d["in_stock"]]
print("Drops you can buy right now:", ", ".join(buyable))
Biggest drop: Burr coffee grinder, now $64.99 (save $15.00)
Drops you can buy right now: Burr coffee grinder, Ceramic pour-over dripper
def check_watch(name, target):
    match = [c for c in changes if c["name"].lower() == name.lower()]
    if not match:
        return f"No product called '{name}'"
    item = match[0]
    if item["price"] <= target:
        return f"Buy now! {item['name']} is ${item['price']:.2f}, under your ${target:.2f}"
    return f"Wait. {item['name']} is ${item['price']:.2f}, above your ${target:.2f}"

watch_name = input("Product to watch: ")
watch_price = float(input("Your target price: $"))
print(check_watch(watch_name, watch_price))
Product to watch: Burr coffee grinder
Your target price: $65
Buy now! Burr coffee grinder is $64.99, under your $65.00
report = {"compared_with": old["date"], "drops": drops, "rises": rises}
report_path = Path("lesson_files/price_report.json")
report_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
saved = json.loads(report_path.read_text(encoding="utf-8"))
print("Saved", report_path.name, "with", len(saved["drops"]), "drops")
Saved price_report.json with 3 drops

Finally, tidy up: stop the server and check that it's really gone.

server.shutdown()
server.server_close()
thread.join(timeout=2)
print("Server thread alive?", thread.is_alive())

try:
    requests.get(BASE + "/shop/index.html", timeout=2)
except requests.exceptions.ConnectionError:
    print("Shop is closed. Goodnight!")
Server thread alive? False
Shop is closed. Goodnight!

Recap#

  • A request goes out, a response comes back: status code, headers and a body
  • Status families: 2xx success, 3xx redirect, 4xx your mistake, 5xx their mistake
  • http.server + threading gives you a safe local web server to practise on
  • requests.get / requests.post with params=, json=, headers= and timeout=
  • .status_code, .ok, .raise_for_status(), .text, .json()
  • Catch Timeout, ConnectionError and HTTPError (all are RequestException)
  • BeautifulSoup: find, find_all, select, select_one, attributes and get_text(strip=True)
  • Turn HTML tables into lists of rows, then into dictionaries with real numbers
  • Scrape politely: robots.txt, delays, caching and an honest User-Agent

Try it yourself#

  1. Price history: append each day's prices to a CSV, then print each product's lowest price ever.
  2. Restock alert: print a message when a product that was sold out yesterday is back in stock today.
  3. A wish list API: add a POST route /api/watchlist that stores products and target prices, and have the tracker read it.

What's next#

Lesson 11: Your First Data Analysis with NumPy and pandas. You'll take data like the prices you scraped today and start answering real questions with it.

Found this useful?

All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.