Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Web Scraping & Browser Automation (Python and JavaScript)  Module 1 – Permission first: robots.txt, terms, law and limits

Collecting web data: API, feed or scraping?

Choose the first permitted source for web data, from an official API and bulk downloads to feeds, JSON-LD and HTML, and see what each request costs the site.

  • Beginner
  • 25 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Choose the first permitted source for a data need, from an official API down to a real browser
  • Compare scraping, crawling and browser automation by the requests each one sends to a site
  • Run the practice shop inside a program, so that no request leaves it
  • Recognise the answers from a site that mean stop, and the legitimate next step for each
What you’ll learn

What you will learn

  • Choose the first permitted source for a data need, from an official API down to a real browser
  • Compare scraping, crawling and browser automation by the requests each one sends to a site
  • Run the practice shop inside a program, so that no request leaves it
  • Recognise the answers from a site that mean stop, and the legitimate next step for each
On this page

Nimbu Bazaar, the practice shop of this track, lists 240 products. You can collect every name and price with one download, three API requests, ten page requests or 250 requests that crawl every product page, and with each step down that list the site does more work and your code gets more fragile.

The order in which to try those sources runs through this whole track: an official API or export first, then bulk downloads, feeds and sitemaps, data already in the page, the HTML, and a real browser last, with the site’s permission checked at every step. Below you will see what scraping, crawling and browser automation each send over HTTP, run the practice shop inside your own program so that no request leaves it, and learn which answers from a site mean stop.

The best source is usually not the HTML

Suppose you need the name and price of every product in a shop. The visible pages are the obvious place to look, and usually the last one worth trying, because most sites that hold data also offer it in a form made for programs. Try these six sources in order and take the first one the site offers for your data:

  1. An official API or export. The site documents it, its fields keep their names from one release to the next, and its terms say how you may use it. Your own data often comes this way: an account page that exports your orders.
  2. A bulk download or open data. One file holds everything, usually under a licence that states its conditions, such as naming the source. Wikimedia, for example, publishes full exports of every public wiki and limits its download servers to 3 connections from one address (Wikimedia downloads), so nobody needs to crawl the articles one page at a time.
  3. A feed or a sitemap. An RSS or Atom feed lists new items; a sitemap lists a site’s pages, up to 50,000 in one file, often with the date each page last changed (sitemaps.org). Both tell you what to fetch, so you skip what has not changed.
  4. Data already in the page. Many pages carry a block of JSON-LD: JSON for Linked Data: a block of machine-readable facts in a page, usually in the schema.org vocabulary, such as a Product whose Offer gives a price and currency. for search engines, such as a schema.org Product whose Offer gives the price and its currency (schema.org). Reading that block is steadier than reading the visible layout.
  5. The HTML. When the data is only in the visible page, you parse the HTML. It works, and a redesign can move every field you read.
  6. A real browser, last. Only when the data appears after the page’s scripts have run does a program need a browser engine, which loads every script, style sheet and image the page names.

At every step one question comes before any code: does the site allow this source for your purpose? Its robots.txt, its terms, the licence of the data and the law each have a say, and the next lessons of this module read them one by one. When no source on the ladder is both offered and allowed, you stop, ask the owner, or look elsewhere. Walk the ladder for one need, the names and prices of all 240 products for a price-comparison site that earns money, and watch both questions at every step:

Interactive · stage by stage Walking the ladder for 240 prices, for a commercial site Step down the six sources. At each one, ask whether the shop offers it for this data and allows it for this use.
Walking the ladder for 240 prices, for a commercial site, step 5 of 5. Showing Official API or export (offered, not for this use), Bulk download (take it: 1 request), Feed or sitemap (no product feed), Data in the page (250 requests), The HTML (10 requests), A real browser (50 requests), Stop and ask the owner. In focus: Stop and ask the owner.Official API or exportoffered, not for this useBulk downloadtake it: 1 requestFeed or sitemapno product feedData in the page250 requestsThe HTML10 requestsA real browser50 requestsStop and ask the owneror choose another source

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The need. You need the name and price of all 240 products of the practice shop, for a price-comparison site that earns money. The purpose matters as much as the data: permission is given for a use, not only for a page.
  2. Step 1: the API. The shop documents an API that returns 100 products a request, 3 requests in all. It is offered, but the shop allows it for non-commercial use only, and this use earns money. Offered, not allowed: step down.
  3. Step 2: the download. The shop publishes all 240 products as one CSV file, under a licence that allows any use as long as the source is named. Offered and allowed: the walk ends here, with 1 request of 7,230 bytes.
  4. What you skipped. Below the download, every source costs the shop more: 250 requests to read the JSON-LD of every product page, 10 for the listing pages, 50 for a browser that loads each page with its files. The shop has no product feed.
  5. When nothing fits. Had the licence ruled out commercial use too, the walk would have gone on down with the same two questions at every step. When no step is both offered and allowed, you stop and ask the owner, or choose another source.

The walk ended at the second step. The API was there, but the shop’s terms allow it for non-commercial use only; the download was there too, under a licence that allows any use as long as the source is named. Had the licence said no as well, the walk would have gone on down with the same two questions, is it offered and is it allowed, and ended with a request to the owner if no step had said yes.

Key idea

Try the sources in order, from an official API down to a real browser, and take the first one that the site both offers for your data and allows for your purpose: each step down costs the site more work and leaves you with more fragile code.

Checkpoint

Check: The order of sources to try

  1. Question 1 of 2 A site documents an API for the product data you need, and its terms allow your use. The same data is also in its HTML pages. Where do you start?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: With the API

    Take the first source on the ladder that the site both offers and allows. Here that is the documented API, the first step, so the HTML and the browser are never needed.

  2. Question 2 of 2 The site's API is for registered partners only, and you are not one. Its product data is also published as a CSV file under a licence that allows your use. What do you do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Download the CSV file, the next step that is both offered and allowed

    At each step ask two questions: is it offered for this data, and is it allowed for this use? The partner-only API fails the second, the licensed file passes both, so the walk ends at the download.

Each method sends a different stream of requests

Three words cover most automated collection, and they describe different work:

  • web scraping: Downloading documents with a program and extracting the fields you need from them, such as a name and a price from each row of a product table. downloads documents with a program and extracts the fields you need, such as a name and a price from each row of a table.
  • web crawling: Discovering documents automatically by following links or reading sitemaps, and fetching each new address found. A crawler usually scrapes what it fetches. discovers the documents: it follows links or reads a sitemap and fetches each address it finds. A crawler usually scrapes what it fetches, so the two often run together.
  • browser automation: Driving a real browser engine from a program: the engine loads a page with every file it names and runs its scripts, as a person's browser would. drives a real browser engine from a program. The engine loads the page as a person’s browser would, with every file the page names, and runs its scripts.

Each request is work the site’s servers do for you: they build or read the page and send its bytes. Step through one collection of the shop’s products by each method, and count the requests as they reach the site:

Interactive · message by message Three methods, three streams of requests to one shop Step through one collection of the shop's 240 products by each method, and count what reaches the shop.
Three methods, three streams of requests to one shop, step 8 of 8. Messages so far: Your program to Nimbu Bazaar: GET /robots.txt; Your program to Nimbu Bazaar: GET /products/?page=1; Your program to Nimbu Bazaar: GET ?page=2, then 3 to 10; Your program to Nimbu Bazaar: GET /products/NB-0001/; Your program to Browser engine: open page 1; Browser engine to Nimbu Bazaar: GET site.css, .js, logo; Browser engine to Nimbu Bazaar: GET /api/v1/products; Your program to Nimbu Bazaar: GET /data/products.csv.Your program250 requestsBrowser engine5 requests a pageNimbu Bazaar1 request for all 240GET /robots.txtGET /products/?page=1GET ?page=2, then 3 to 10GET /products/NB-0001/open page 1GET site.css, .js, logoGET /api/v1/productsGET /data/products.csv

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. Rules first. Every method starts the same way, with one request for the shop's rules: its robots.txt, which the next lessons read line by line. The counts in the steps below leave this one request out.
  2. Scraping a page. A scraper fetches a page and extracts the fields it needs. One listing page holds 24 products, so the program reads 24 names and prices from one response.
  3. The next pages. It finds each next page in the Link header of the one before, instead of guessing addresses, and stops at the page without one: 10 requests for all 240 products.
  4. Crawling. A crawler also follows every product link on those pages and fetches each product's own page: 240 more requests, each for a page about one product, 250 in all.
  5. Automation. Browser automation hands the address to a browser engine, which opens the page the way a person's browser would.
  6. The page's files. The engine fetches the page, then every file it names: a style sheet, a script and a logo. Those three requests carry no products at all.
  7. The script's call. The page's script asks the API for the 24 products it draws. That makes 5 requests for every page of 24 products, 50 for the whole catalogue.
  8. The file instead. For comparison, the shop's CSV file holds all 240 products in one response of 7,230 bytes. One request, and nothing to parse but the rows.

The program below collects the 240 names and prices from the practice shop in five ways, the three methods and two of the ladder’s better sources, and adds up the requests and bytes the shop sends back for each:

Five ways to collect 240 products, counted Python · lab/request_costs.py
"""Five ways to collect the name and price of all 240 products, and what each one asks of the site."""
import json
from html.parser import HTMLParser

import nimbu_lab
from nimbu_lab import fetch

SITE = "https://bazaar.nimbu.test"
BOT = "NimbuLearner/1.0 (+https://example.org/my-bot; you@example.org)"


class Links(HTMLParser):
    """Collects the addresses a page points to: links, and the files a browser would load."""

    def __init__(self):
        super().__init__()
        self.pages, self.files, self.api = [], [], None

    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        if tag == "a":
            self.pages.append(a["href"])
        elif tag in ("script", "img") and "src" in a:
            self.files.append(a["src"])
        elif tag == "link" and a.get("rel") == "stylesheet":
            self.files.append(a["href"])
        elif tag == "table":
            self.api = a.get("data-api")


def next_link(response):
    """The address in a Link: <...>; rel="next" header, or None on the last page."""
    link = response.headers.get("Link", "")
    return link[1:link.index(">")] if 'rel="next"' in link else None


def listing_pages():
    """Follow the catalogue's next links from page 1, yielding each page's address and parsed links."""
    url = "/products/?page=1"
    while url:
        page = fetch(SITE + url, user_agent=BOT)
        links = Links()
        links.feed(page.text)
        yield url, links
        url = next_link(page)


def bulk_file():
    rows = fetch(SITE + "/data/products.csv", user_agent=BOT).text.splitlines()[1:]
    return len(rows)


def api_pages():
    count, url = 0, "/api/v1/products?page=1&per_page=100"
    while url:
        answer = fetch(SITE + url, user_agent=BOT)
        count += len(json.loads(answer.body)["items"])
        url = next_link(answer)
    return count


def html_pages():
    return sum(len(links.pages) for _, links in listing_pages())


def crawl_product_pages():
    count = 0
    for _, links in listing_pages():
        for href in links.pages:
            fetch(SITE + href, user_agent=BOT)
            count += 1
    return count


def browser_like():
    """No browser runs here: this asks for what each listing page tells a browser to load."""
    count = 0
    for _, links in listing_pages():
        for file in links.files:
            fetch(SITE + file, user_agent=BOT)
        api = fetch(SITE + links.api.replace("&amp;", "&"), user_agent=BOT)
        count += len(json.loads(api.body)["items"])
    return count


print(f"{'method':32} {'requests':>8} {'bytes':>9} {'products':>8}")
for name, collect in [("bulk download (one CSV file)", bulk_file), ("API, 100 products a request", api_pages),
                      ("HTML listing pages", html_pages), ("crawl every product page", crawl_product_pages),
                      ("what a browser loads", browser_like)]:
    nimbu_lab.LOG.clear()
    found = collect()
    sent = len(nimbu_lab.LOG)
    print(f"{name:32} {sent:8} {sum(e['bytes'] for e in nimbu_lab.LOG):9,} {found:8}")

Output

method                           requests     bytes products
bulk download (one CSV file)            1     7,230      240
API, 100 products a request             3    14,993      240
HTML listing pages                     10    24,288      240
crawl every product page              250   117,227      240
what a browser loads                   50   121,130      240

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 request_costs.py

The table is the case for the ladder in numbers. The CSV file costs the shop 1 request and 7,230 bytes. The API needs 3 requests, the listing pages 10, and crawling every product page 250 requests and 117,227 bytes, sixteen times the bytes of the file for the same 240 rows. At a polite pace of one request a second, the file takes a second to collect and the crawl more than four minutes. The browser row needs a word of care: no browser runs in this program. It asks for what each listing page tells a browser to load (a style sheet, a script, a logo and the script’s call to the API), 5 requests for every page of 24 products. The practice shop’s files are small stand-ins; on real sites the scripts and images are often far larger than the data they display.

Change the page size

Press Edit, change per_page=100 to per_page=24 in api_pages() and run it again. The API now needs 10 requests, as many as the listing pages, and still sends 15,242 bytes against their 24,288, because JSON carries no layout.

Checkpoint

Check: Scraping, crawling and browser automation

  1. Question 1 of 2 A shop lists 300 products, 25 to a listing page, and every product has its own page. A crawler fetches every listing page and every product page. How many requests does it send, leaving out robots.txt?

    Type a number.

    A hint

    Count the listing pages first, then add one request for each product page.

    Show the answer: Question 1 of 2

    Answer: 312 requests

    300 products at 25 a page fill 12 listing pages, and the crawler then fetches 300 product pages: 12 + 300 = 312 requests. The practice shop's 240 products at 24 a page gave 10 + 240 = 250 in the program.

  2. Question 2 of 2 In the comparison program, why did "what a browser loads" send 5 requests for every listing page of 24 products?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The page itself, its style sheet, its script, its logo, and the script's call to the API

    No browser ran in the program: it asked for what each listing page tells a browser to load, the page, a style sheet, a script and a logo, plus the script's API call, 5 requests a page and 50 for the 10 pages.

The practice site runs inside your program

Every example in this track collects from Nimbu Bazaar, a made-up shop whose products, prices and pages were generated for practice and describe nothing real. Learning on it means your mistakes, a loop that fetches the same page a thousand times or a missing pause between requests, cost nobody anything.

In the examples the shop runs inside your own program. It is a WSGI: The Web Server Gateway Interface of PEP 3333: the standard way a Python web server calls an application, a function that takes a dictionary describing a request and returns the response body. application: the standard interface between Python web servers and web applications, defined in PEP 3333, in which the application is a function that receives a dictionary describing one request and returns the body of the response (PEP 3333). The helper fetch() builds that dictionary from an address and calls the function directly, so the request never reaches a network. Follow one request through the two files of the next example:

Interactive · step by step One request to the practice shop, inside your program Step through first_request.py asking for page 1 of the products, and see that the network is never used.
One request to the practice shop, inside your program, step 7 of 7. Showing first_request.py, The network, fetch() (status 200, headers, body), app() (host checked), LOG. In focus: first_request.py.first_request.pyyour programThe networknot usedfetch()status 200, headers, bodyapp()host checkedLOGwhat the shop sawa response

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. A call, not a connection. The program calls fetch() with the address of page 1 and its User-Agent. Nothing has been sent anywhere yet: fetch() is an ordinary Python function.
  2. The request as a dictionary. fetch() splits the address into the pieces WSGI expects, the method GET, the path /products/, the query page=1, the host and the User-Agent, and puts them in one dictionary.
  3. The shop is called. fetch() calls app() with that dictionary. The shop first checks the host: an address on any other host gets 421 Misdirected Request.
  4. The shop keeps a log. Before it answers, the shop writes the request into its log: the path, the status, the bytes and the User-Agent. Later lessons read this log to see how polite a collector was.
  5. The answer comes back. The shop passes 200 OK and the headers to start_response, and returns the page: 2,426 bytes of HTML with a Link header to page 2.
  6. No byte left the program. No socket was opened, so the dashed arrow to the network was never used. That is why the examples print the same in the browser runner as on a computer.
  7. Back in your program. fetch() hands back a response with the status, the headers and the body, and the program prints them. The next request goes through the same steps.

Here are both files. nimbu_lab.py is the shop; first_request.py asks it for its robots.txt and the first page of products, the way the next lessons will. Run it, then press Edit and change the address:

A first request to the practice shop, inside your program

lab/first_request.py

from nimbu_lab import fetch

BOT = "NimbuLearner/1.0 (+https://example.org/my-bot; you@example.org)"

robots = fetch("https://bazaar.nimbu.test/robots.txt", user_agent=BOT)
print(robots.status, robots.headers["Content-Type"])
print(robots.text)

page = fetch("https://bazaar.nimbu.test/products/?page=1", user_agent=BOT)
print(page.status)
for name in ["Content-Type", "Content-Length", "Link"]:
    print(f"{name}: {page.headers[name]}")
first_row = [line for line in page.text.splitlines() if line.startswith("<tr><td>")][0]
print(first_row)

Output

200 text/plain; charset=utf-8
User-agent: *
Disallow: /account/
Disallow: /people/
Allow: /products/
Sitemap: https://bazaar.nimbu.test/sitemap.xml

200
Content-Type: text/html; charset=utf-8
Content-Length: 2426
Link: </products/?page=2>; rel="next"
<tr><td><a href="/products/NB-0001/">Basmati rice 250 g</a></td><td>₹25.00</td></tr>

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 first_request.py

nimbu_lab.py (the shop) · lab/nimbu_lab.py

"""Nimbu Bazaar, the practice shop of this track. Every product and price in it is invented.

The site is a WSGI application (PEP 3333): a Python function that takes a request and returns a response.
fetch() calls that function inside your own program, so no request travels over a network.
"""
import csv
import io
import json
from urllib.parse import parse_qs, urlsplit
from wsgiref.util import setup_testing_defaults

HOST = "bazaar.nimbu.test"
PER_PAGE = 24
KINDS = ["Basmati rice", "Toor dal", "Green tea", "Jaggery", "Turmeric", "Mustard oil", "Poha", "Rock salt",
         "Cashews", "Cardamom", "Ragi flour", "Ghee", "Honey", "Peanuts", "Coffee", "Raisins", "Sesame seeds",
         "Tamarind", "Cumin", "Black pepper", "Rolled oats", "Cloves", "Fennel", "Moong dal", "Chana dal",
         "Wheat flour", "Semolina", "Coriander", "Red chilli", "Almonds", "Dates", "Coconut oil", "Brown rice",
         "Millet", "Flax seeds", "Cinnamon", "Ginger powder", "Vermicelli", "Masoor dal", "Sago"]
SIZES = [250, 500, 1000, 2000, 5000, 10000]  # grams
ROBOTS = """\
User-agent: *
Disallow: /account/
Disallow: /people/
Allow: /products/
Sitemap: https://bazaar.nimbu.test/sitemap.xml
"""
STATIC = {"/static/site.css": b"body{font:16px sans-serif}" * 40,
          "/static/catalogue.js": b"/* draws the table */" * 300,
          "/static/logo.svg": b"<svg xmlns='http://www.w3.org/2000/svg'/>" * 20}
LOG = []  # one entry per request: what the site saw


def products():
    """The 240 products: 40 kinds in 6 pack sizes, each price in paise (100 paise = 1 rupee)."""
    out = []
    for k, kind in enumerate(KINDS):
        per_kg = 60 + (k * 37) % 900  # rupees a kilogram
        for grams in SIZES:
            size = f"{grams // 1000} kg" if grams >= 1000 else f"{grams} g"
            out.append({"sku": f"NB-{len(out) + 1:04d}", "name": f"{kind} {size}",
                        "paise": per_kg * grams // 10 + 1000})
    return out


def rupees(paise):
    return f"₹{paise // 100:,}.{paise % 100:02d}"


def listing(page):
    """One page of the catalogue as HTML: 24 products, the files a browser loads, a link to the next page."""
    items = products()[(page - 1) * PER_PAGE:page * PER_PAGE]
    rows = "".join(f'<tr><td><a href="/products/{p["sku"]}/">{p["name"]}</a></td><td>{rupees(p["paise"])}</td></tr>\n'
                   for p in items)
    return ("<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\">"
            f"<title>Products, page {page} · Nimbu Bazaar</title>\n"
            '<link rel="stylesheet" href="/static/site.css"><script src="/static/catalogue.js" defer></script>\n'
            '</head><body><img src="/static/logo.svg" alt="Nimbu Bazaar">\n'
            f'<table id="products" data-api="/api/v1/products?page={page}&amp;per_page={PER_PAGE}">\n'
            f"<tr><th>Product</th><th>Price</th></tr>\n{rows}</table></body></html>\n")


def product_page(p):
    """A product's own page, with its name and price as JSON-LD (schema.org Product and Offer)."""
    data = {"@context": "https://schema.org", "@type": "Product", "sku": p["sku"], "name": p["name"],
            "offers": {"@type": "Offer", "price": f'{p["paise"] / 100:.2f}', "priceCurrency": "INR"}}
    return (f'<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><title>{p["name"]}</title>\n'
            f'<script type="application/ld+json">{json.dumps(data)}</script></head>\n'
            f'<body><h1>{p["name"]}</h1><p class="price">{rupees(p["paise"])}</p></body></html>\n')


def app(environ, start_response):
    """The site: answers one request."""
    path, query = environ["PATH_INFO"], parse_qs(environ.get("QUERY_STRING", ""))
    page = int(query.get("page", ["1"])[0])
    by_sku = {p["sku"]: p for p in products()}
    status, kind, extra, body = "200 OK", "text/html; charset=utf-8", [], b""
    if environ.get("HTTP_HOST") != HOST:
        status, kind, body = "421 Misdirected Request", "text/plain", b"not a Nimbu Bazaar address"
    elif path == "/robots.txt":
        kind, body = "text/plain; charset=utf-8", ROBOTS.encode()
    elif path == "/products/" and 1 <= page <= 10:
        body = listing(page).encode()
        if page < 10:
            extra = [("Link", f'</products/?page={page + 1}>; rel="next"')]
    elif path.startswith("/products/NB-") and path[10:-1] in by_sku:
        body = product_page(by_sku[path[10:-1]]).encode()
    elif path == "/api/v1/products":
        per_page = min(int(query.get("per_page", ["24"])[0]), 100)
        items = products()[(page - 1) * per_page:page * per_page]
        last = (len(products()) + per_page - 1) // per_page
        kind, body = "application/json", json.dumps({"page": page, "pages": last, "items": items}).encode()
        if page < last:
            extra = [("Link", f'</api/v1/products?page={page + 1}&per_page={per_page}>; rel="next"')]
    elif path == "/data/products.csv":
        out = io.StringIO()
        writer = csv.writer(out, lineterminator="\n")
        writer.writerow(["sku", "name", "price_inr"])
        writer.writerows([p["sku"], p["name"], f'{p["paise"] / 100:.2f}'] for p in products())
        kind, body = "text/csv; charset=utf-8", out.getvalue().encode()
    elif path in STATIC:
        kind, body = "application/octet-stream", STATIC[path]
    else:
        status, kind, body = "404 Not Found", "text/plain", b"no such page"
    LOG.append({"path": path + ("?" + environ["QUERY_STRING"] if environ.get("QUERY_STRING") else ""),
                "status": int(status[:3]), "bytes": len(body), "user_agent": environ.get("HTTP_USER_AGENT", "")})
    start_response(status, [("Content-Type", kind), ("Content-Length", str(len(body)))] + extra)
    return [body]


class Response:
    def __init__(self, status, headers, body):
        self.status, self.headers, self.body = status, headers, body
        self.text = body.decode("utf-8")


def fetch(url, user_agent):
    """GET a Nimbu Bazaar address by calling app() directly: nothing leaves your program."""
    parts = urlsplit(url)
    environ = {"REQUEST_METHOD": "GET", "PATH_INFO": parts.path or "/", "QUERY_STRING": parts.query,
               "HTTP_HOST": parts.netloc, "HTTP_USER_AGENT": user_agent, "wsgi.url_scheme": parts.scheme}
    setup_testing_defaults(environ)
    answer = {}

    def start_response(status, headers, exc_info=None):
        answer["status"], answer["headers"] = int(status[:3]), dict(headers)

    body = b"".join(app(environ, start_response))
    return Response(answer["status"], answer["headers"], body)

The program sent two requests and printed what came back: status 200 and the shop’s robots.txt for the first, then the headers of the first listing page. Content-Length says the page is 2,426 bytes, and the Link header points to the next page, which is how a collector learns where to go without guessing addresses. The last line is the first product row exactly as the server sent it, HTML tags and all; reading the fields out of such rows is the work of the parsing module. The user_agent argument names the program and says how to reach you, and goes out as the User-Agent: The HTTP request header that names the software sending the request. An honest collector puts its own name, version and a contact address in it. header of every request. HTTP defines that header to identify the software that sends a request, and asks a robot to send a contact address too, so that whoever runs it can be reached when it causes trouble (RFC 9110, sections 10.1.5 and 10.1.2).

Practising on a real site

A script you are still learning to write sends a real site its mistakes: requests in a tight loop, wrong addresses and no pause between them. Practise on the lab shop, and point a collector at a real site only once its code is polite and the site allows what it does.

On your own computer the same application can also listen as a small web server on the address 127.0.0.1, which only your own machine can reach. The next lesson sets that up, together with the versions of Python and Node this track uses.

HTTP Header Checker See the headers a real page sends, such as Content-Type, Content-Length and Link, and compare them with the shop's.

Checkpoint

Check: The practice shop inside your program

  1. Question 1 of 2 In the examples of this track, how does fetch() reach the practice shop?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It calls the shop's WSGI function directly, inside the same program

    The shop is a WSGI application, a function that takes a dictionary describing a request and returns the response body. fetch() calls it directly, which is why the examples print the same everywhere and send nothing anywhere.

  2. Question 2 of 2 The answer for page 1 of the products carries the header Link: </products/?page=2>; rel="next". What does a collector do with it?
    Show the answer: Question 2 of 2

    Answer: Fetch page 2 from that address, and stop at the first page that has no next link

    A Link header with rel="next" points to the next page. Following it until a page has none fetches every page exactly once: 10 requests for the shop's 240 products.

Some answers mean stop

A site answers a collector in more ways than with pages. Some answers ask you to slow down, some refuse, and some ask whether you are a person. Each one has a legitimate next step, and none of them is a puzzle to get around:

What you meet What it means What you do
robots.txt disallows the path The site asks bots to stay out of it Skip the path; for your own data, use the site’s export
401 or a login page The data needs an account Collect only your own account’s data, and only where the terms allow automated use
403 Forbidden The server understood and refuses Stop; do not repeat the request with another identity
429 Too Many Requests You sent too many requests too quickly Wait for the time in Retry-After, then slow down
A “verify you are human” page or a CAPTCHA The site suspects automation Stop, and ask the owner for access or an API
Terms that forbid automated access The site’s contract says no Ask in writing, or choose another source

The status codes come from the HTTP standards: a 403 means the server understood the request and refuses it, and a client should not repeat it automatically with the same credentials (RFC 9110, section 15.5.4); a 429 says the client sent too many requests in a given time and may carry a Retry-After header that says how long to wait (RFC 6585, section 4). Later lessons handle each answer in code.

What this track never does

It never bypasses a CAPTCHA, a login, a paywall, a rate limit or a bot check, never rotates addresses, proxies, User-Agent strings or browser fingerprints to avoid a block, and never claims to be another crawler. Wikimedia’s User-Agent policy is one site’s plain statement of the same rule: it asks bots not to copy a browser’s User-Agent and treats bot traffic dressed as a browser as malicious.

A block is information about the site’s wishes. A collector that stops at the first block, logs what it met and lets a person decide what to ask for keeps working with the site instead of against it.

Checkpoint

Check: Answers that mean stop

  1. Question 1 of 2 Your collector receives 403 Forbidden for a page it wants. What does it do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Stop asking for that page, record the answer and let a person decide

    403 is a refusal: the collector stops for that page and records what happened. Asking the owner or using another source is a decision for a person, never a change of identity.

  2. Question 2 of 2 After 40 requests a site answers with a "verify you are human" page. What is the legitimate next step?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Stop collecting, log what happened and ask the owner for access or an API

    A CAPTCHA or a "verify you are human" page is the site saying it does not want automated visits there. The only correct reaction is to stop, and then to ask for access or look for an API or a download.

Practise: choose the first source

The exercise gives you the ladder as a list and asks for two things: a function that returns the first source a site both offers and allows, or “stop” when there is none, and your answer for twelve real situations, from an export of your own orders to a forum behind a login. The sample tests explain every answer they reject:

Exercise · Easy · Python

Choose the first permitted source for twelve data needs

sources.py gives you the ladder of this lesson as a list, from the first source to try to the last:

LADDER = ["api", "download", "feed", "page-data", "html", "browser"]

That is an official API or export, a bulk download, a feed or sitemap, data already in the page (such as JSON-LD), the HTML, and a real browser.

  1. Write first_source(offered, allowed). offered is the set of sources a site offers for the data you need, and allowed the set its robots.txt, terms and licence allow for your purpose. Return the first source of LADDER that is in both sets, or "stop" when there is none.
  2. Fill in ANSWERS: for each of the twelve needs below, the source you would use first, or "stop".

The twelve needs:

  1. Your own past orders from a shop whose account page offers "Download my orders" as a CSV file.
  2. A city's bus timetable, published as one zip file under an open licence and also shown on its web pages.
  3. Each morning's new articles from a news site that offers an RSS feed of them; its pages may be collected too.
  4. The name, price and currency of 30 products whose pages carry a JSON-LD Product block; there is no API, file or feed.
  5. A rainfall table that exists only on one council web page, which robots.txt allows.
  6. Seat counts that a page shows only after its script has run; the site offers nothing else and allows automated access for personal use, which yours is.
  7. A site has an API for registered partners only (you are not one) and allows its HTML pages to be collected for non-commercial research, which yours is.
  8. Seller profiles under /people/, which robots.txt disallows and the terms forbid collecting; nothing else offers them.
  9. All 240 products of the practice shop for a site that earns money: the API and the pages are allowed for non-commercial use only, and the CSV file's licence allows any use with attribution.
  10. Which of 50,000 articles changed since yesterday, on a site whose sitemap gives each article's last change; its pages may be collected.
  11. Tables from a statistics office that offers an API with a key you request online and documented limits, and the same tables as web pages.
  12. Posts in a forum that shows them only after you log in; you have an account, the terms forbid automated access to the logged-in pages, and there is no API.

The sample tests check first_source on its own, then each answer, and say what a wrong answer misses.

Starter code · sources.py

LADDER = ["api", "download", "feed", "page-data", "html", "browser"]


def first_source(offered, allowed):
    """The first source in LADDER that the site both offers and allows, or "stop"."""
    # Replace this: scraping the HTML first is the habit this exercise breaks.
    return "html"


# The source you would use first for each of the twelve needs in the prompt, or "stop".
ANSWERS = {}
The sample tests · test_sources.py
from sources import ANSWERS, LADDER, first_source

EXPECTED = {
    1: ("api", "the shop's own export of your orders is an official export: the first source on the ladder"),
    2: ("download", "one openly licensed file comes before reading the timetable off its pages"),
    3: ("feed", "the feed lists the new articles, so you fetch only what is new"),
    4: ("page-data", "the JSON-LD block carries the name, price and currency for programs"),
    5: ("html", "the table exists only in the page, and robots.txt allows it"),
    6: ("browser", "the data appears only after the script runs, and nothing else offers it"),
    7: ("html", "the API is offered but not allowed for you; the pages are allowed for your use"),
    8: ("stop", "robots.txt and the terms both say no, and nothing else offers the profiles"),
    9: ("download", "the API and the pages are not allowed for a use that earns money; the file's licence allows it"),
    10: ("feed", "the sitemap gives each article's last change, so you fetch only what changed"),
    11: ("api", "a documented API with a key and limits is the first source to use"),
    12: ("stop", "the terms forbid automated access to the logged-in pages, and there is no API"),
}


def test_takes_the_first_source_that_is_offered_and_allowed():
    """returns the first source of LADDER in both sets"""
    assert first_source({"api", "html"}, {"api", "html"}) == "api"
    assert first_source({"html", "download"}, {"download", "html", "browser"}) == "download"
    assert first_source({"browser", "feed"}, {"browser", "feed"}) == "feed"


def test_skips_a_source_that_is_offered_but_not_allowed():
    """an offered source the site does not allow is never the answer"""
    assert first_source({"api", "html"}, {"html"}) == "html"
    assert first_source({"api", "download", "html"}, {"download"}) == "download"


def test_skips_a_source_that_is_allowed_but_not_offered():
    """a source the site allows but does not offer for this data is never the answer"""
    assert first_source({"html"}, {"api", "download", "html"}) == "html"


def test_stops_when_no_source_is_both():
    """returns "stop" when no source is both offered and allowed"""
    assert first_source({"api", "html"}, {"download"}) == "stop"
    assert first_source(set(), set(LADDER)) == "stop"
    assert first_source(set(LADDER), set()) == "stop"


def test_answers_for_the_twelve_needs():
    """the first source for each need of the prompt"""
    assert sorted(ANSWERS) == list(range(1, 13)), "give an answer for each need, 1 to 12"
    for need, (want, why) in EXPECTED.items():
        assert ANSWERS[need] == want, f"need {need}: {want}, because {why} (you wrote {ANSWERS[need]!r})"
A hint

Walk LADDER from the start and return the first source that appears in offered and in allowed; after the loop, return "stop". For the answers, ask the same two questions of each need in ladder order: is this source offered for the data, and is it allowed for this use? Need 9 has a source that is offered but not allowed.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

After the exercise, the self-check goes over the ladder, the three methods, the lab shop and the answers that mean stop. It picks its first question from whichever of the four your checkpoint answers found hardest:

Practice that adapts to you

Practise this lesson

12 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 12 Put the sources in the order to try them, the first one first.

    Give each item its position, from 1 (first).

    Show the answer: Question 1 of 12

    Answer:

    1. An official API or export
    2. A bulk download or open data
    3. A feed or a sitemap
    4. Data already in the page, such as JSON-LD
    5. The HTML
    6. A real browser

    From the source made for programs to the one that costs the site the most: an API or export, a download, a feed or sitemap, data in the page, the HTML, and a real browser last.

  2. Question 2 of 12 Product pages carry a JSON-LD Product block with the name, price and currency, and the site offers no API, download or feed. Why read the JSON-LD rather than the price shown on the page?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: It is written for programs, with named fields, so a redesign of the visible page does not move it

    Data already in the page comes before the HTML on the ladder because it has named fields, such as an Offer's price and priceCurrency, that do not move when the layout changes.

  3. Question 3 of 12 You run a site that earns money and need the practice shop's 240 prices. Its API and pages may be collected for non-commercial use only; its CSV file's licence allows any use with attribution. What do you use?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: The CSV file, with the attribution its licence asks for

    Permission is given for a use. The API and the pages say no to commercial use; the file's licence says yes with attribution, so the walk ends at the download, with 1 request of 7,230 bytes.

  4. Question 4 of 12 Which sentence describes web crawling?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: It discovers documents by following links or reading sitemaps, and fetches each address it finds

    Scraping extracts, crawling discovers, and browser automation drives a browser engine. A crawler usually scrapes what it fetches, which is why the words are often mixed up.

  5. Question 5 of 12 In the comparison run, the crawl of every product page received 117,227 bytes and the CSV file 7,230. About how many times the file's bytes did the crawl receive? Round to a whole number.

    Type a number.

    Show the answer: Question 5 of 12

    Answer: 16 times (anything from 15.5 to 16.5 counts)

    117,227 ÷ 7,230 is about 16.2, so the crawl received about 16 times the bytes for the same 240 rows, over 250 requests instead of 1.

  6. Question 6 of 12 A real browser can collect almost any page. Why is it still the last source to try?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: It requests every file the page names and runs its scripts, which costs the site the most and is slowest for you

    Each step down the ladder costs the site more work. A browser engine is at the bottom because it loads the page with every file it names and runs its scripts, even when the data needed one request.

  7. Question 7 of 12 What is a WSGI application, such as the practice shop's app()?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: A Python function that receives a dictionary describing one request and returns the body of the response

    A WSGI application is a callable that takes the request's environ dictionary and a start_response function, and returns the response body. Because it is a function, a program can call it without any network.

  8. Question 8 of 12 A program calls fetch("https://shop.example/products/", user_agent=BOT) with the practice shop's fetch(). What does the shop answer?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: 421 Misdirected Request, because it answers only for its own host

    The shop answers only for bazaar.nimbu.test and refuses every other host with 421, so an address typed for a real site cannot reach anything by mistake.

  9. Question 9 of 12 Your first collector has a bug that fetches the same page in a loop. Why does it matter where you run it?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: On the practice shop the loop costs nobody anything; on a real site it sends the owner your bug as load

    Practise on the lab shop, where every request stays inside your program, and point a collector at a real site only once its code is polite and the site allows what it does.

  10. Question 10 of 12 A site answers 429 Too Many Requests with the header Retry-After: 120. What do you do?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: Send nothing more to that site for 120 seconds, then continue more slowly

    429 means too many requests in a given amount of time, and Retry-After: 120 asks for 120 seconds of quiet. After that, a collector continues at a slower pace.

  11. Question 11 of 12 Which of these does this track never do? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 11 of 12

    Answer:

    • Send a User-Agent that claims to be another crawler or a browser
    • Solve a CAPTCHA in code
    • Rotate proxies or addresses to avoid a block

    The track never bypasses CAPTCHAs, logins, paywalls, rate limits or bot checks, never rotates identities to avoid a block, and never claims to be another crawler. It always identifies itself and waits when asked to.

  12. Question 12 of 12 robots.txt allows every bot to fetch /prices/, but the site's terms forbid automated collection. What do you do?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: Treat the terms as a no for automated collection, and ask in writing or choose another source

    Permission comes in layers. A path that robots.txt allows can still be off limits under the terms, so the collector stops there and a person asks the owner or looks for another source.

Interview questions

Warm-up (fresher): What is the difference between web scraping and web crawling?

Web scraping extracts data from documents a program has fetched: it reads a page, a feed or a file and pulls out fields such as a product’s name and price. Web crawling discovers documents: it starts from some addresses, follows links or reads sitemaps, and fetches each new address it finds, usually within rules such as robots.txt. A crawler usually scrapes what it fetches, so the two often run together, but either can run alone: a scraper can work on ten pages you listed by hand, and a web archive’s crawler stores each page whole without extracting any field. Both send requests that cost the site work, so both follow the same permissions.

Two harder questions grow out of this lesson: a partner that wants its own prices collected, and a site owner who writes to say your collector is slowing the site down. The question page of this module gives a first step and the follow-ups for both:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    A partner company wants a daily copy of the prices of its own 2,000 products, which its public website shows on listing pages of 24. It asks you to write a scraper for its site. What do you check, and in what order, before the first request?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    Your collector fetches about 500 pages an hour from a small community site. Its owner emails that the site has slowed down since your collector started. What do you do in the first hour, and what do you change before it runs again?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Try the sources in order, an official API or export, a bulk download, a feed or sitemap, data in the page, the HTML and a real browser last, and take the first one the site both offers and allows for your purpose.
  • Scraping extracts fields from documents, crawling discovers documents by following links or sitemaps, and browser automation drives a real browser engine that loads every file a page names.
  • In the practice shop the same 240 products cost 1 request as a file, 3 through the API, 10 as listing pages and 250 as a crawl of every product page.
  • The practice shop is a WSGI application that the examples call inside their own program, so no request reaches a network while you learn.
  • A disallow rule, a 403, a 429, a CAPTCHA or terms that say no each mean stop or slow down, and the legitimate next step is to wait, ask the owner or use another source.

Terms in this lesson

browser automation
Driving a real browser engine from a program: the engine loads a page with every file it names and runs its scripts, as a person's browser would.
JSON-LD
JSON for Linked Data: a block of machine-readable facts in a page, usually in the schema.org vocabulary, such as a Product whose Offer gives a price and currency.
User-Agent
The HTTP request header that names the software sending the request. An honest collector puts its own name, version and a contact address in it.
web crawling
Discovering documents automatically by following links or reading sitemaps, and fetching each new address found. A crawler usually scrapes what it fetches.
web scraping
Downloading documents with a program and extracting the fields you need from them, such as a name and a price from each row of a product table.
WSGI
The Web Server Gateway Interface of PEP 3333: the standard way a Python web server calls an application, a function that takes a dictionary describing a request and returns the response body.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.