Programming with Python › Python for Automation and the Web › Day 79
Hands-on lab — Day 79: Web Scraping Responsibly
- ← Back to the Day 79 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/programming-with-python/day-079-web-scraping-responsibly/
Commands
Setup
cd labs/sections/programming-with-python/day-079-web-scraping-responsibly
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt Run
cat examples/fixtures/robots.txt
PYTHONPATH=examples .venv/bin/python3 examples/fixture_server.py
PYTHONPATH=examples .venv/bin/python3 examples/regex_vs_parser.py
PYTHONPATH=examples .venv/bin/python3 examples/demo.py catalogue.csv
head -4 catalogue.csv
SCRAPER_MODULE=starter .venv/bin/pytest tests -q Test
bash tests/run_tests.sh File tree
examples/catalogue_scraper.py examples/demo.py examples/fixture_server.py examples/fixtures/catalogue/page-1.html examples/fixtures/catalogue/page-2.html examples/fixtures/catalogue/page-3.html examples/fixtures/detour/page-1.html examples/fixtures/index.html examples/fixtures/private/internal-notes.html examples/fixtures/robots.txt examples/fixtures/sitemap.txt examples/regex_vs_parser.py expected-output/catalogue.csv expected-output/FIELDS.md expected-output/regex-vs-parser.txt expected-output/sample-run.txt expected-output/test-run.txt metadata.yml README.md requirements/README.md requirements/requirements.txt security.md starter/catalogue_scraper.py tests/run_tests.sh tests/test_scraper.py troubleshooting.md
Lab README
Day 079 lab — Scrape a Site You Are Allowed To
Lesson
- Lesson title: Web Scraping Responsibly
- Day number: 79 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-079-web-scraping-responsibly
- Lab files: everything you need is in this directory — follow “How to run” below.
- Browse the course locally: from the repository root, this lab also appears in the course website at
/labs/day-079-web-scraping-responsiblywhen the site is running.
Purpose
You are going to write a web scraper. It will fetch pages over real HTTP, read
a real robots.txt, follow real pagination, survive real HTML mess, cache what
it fetches, and write a CSV. The one thing it will not do is touch anybody
else's server.
The site is the Harbour Chandlery, a three-page fake catalogue that ships
in examples/fixtures/ and is served to you from 127.0.0.1 on a port the
operating system picks at run time. It is small, it is offline, and it is
deliberately awkward in five specific ways that real sites are awkward:
| The mess | Where | Why it is there |
|---|---|---|
| a row with no price element at all | NAV-003, page 2 |
the single most common scraping crash |
a cell with two classes, class="name featured" |
NAV-002, page 1 |
defeats the obvious regular expression, not the parser |
an HTML entity, Ink & Quill Set |
STA-001, page 1 |
the parser decodes it; a regex hands you the raw text |
| a decorative nested tag inside a name cell | STA-002, page 1 |
get_text sweeps up every descendant unless you say otherwise |
| text wrapped in newlines and indentation | TOO-003, page 3 |
HTML authors format for humans, not for you |
And one more thing, which is the point of the day: the catalogue's first page
links to /private/internal-notes.html, and the site's robots.txt disallows
/private/ for every client. The server will serve that page to anybody who
asks. The test harness asserts, against the server's own access log, that
your scraper never asked. That assertion is the difference between a lesson
about ethics and a scraper that has them.
Learning objectives
- Fetch and parse
robots.txtwithurllib.robotparser, and ask it the two questions that matter: may I fetch this URL as this client, and how long must I wait between requests. - Send an honest
User-Agentthat names your program and gives a way to reach you, and confirm from the server side that exactly one was sent. - Refuse a disallowed URL before a socket is opened, and prove the refusal from the server's log rather than from your own claim.
- Extract a table with CSS selectors, and explain concretely why the obvious regular expression misses rows on valid HTML.
- Handle a missing element by design —
select_onereturningNoneis an ordinary case, not an exception. - Follow pagination until the site says there is no next page, guard against a loop, and refuse a link that points at another host.
- Cache responses on disk so that a second run of your parser makes zero page
requests, and understand why
robots.txtis the one thing you never cache. - Write the result to CSV with the
csvmodule, putting an empty field where a value is missing. - Inject the network session, the cache and the clock as parameters, so the suite can test a one-second crawl delay without waiting one second.
Prerequisites
- The Day 79 lesson — read it first; the ethics half of it is the half this lab enforces.
- Day 78: HTTP,
requests,Session, timeouts, status codes, headers. - Day 74: testing at a boundary by injecting it rather than reaching for it.
- Days 71–73: pytest, fixtures, and reading a failing test.
- Day 65: the
csvmodule,newline="", andDictReader. - Day 69: dataclasses and type hints.
- Day 43: creating and using a virtual environment.
- A terminal and a text editor. Nothing beyond this course is assumed.
Supported operating systems
- macOS — fully supported. Executed on macOS 26.5.1 (Apple Silicon), Python 3.14.0, bash 3.2.57.
- Linux — fully supported; identical output. Everything used is either standard library or a pure-Python package.
- Windows — run inside WSL, where the commands below work unchanged.
Outside WSL,
bash tests/run_tests.shneeds Git Bash or WSL, and the venv paths become.venv\Scripts\pythonand.venv\Scripts\pip.
Hardware requirements
Any machine that runs Python 3. The whole fixture site is under 20 KB, the crawl is three pages, and the suite finishes in about a second.
Required software
- Python 3.9 or newer (tested on 3.14.0).
bashfor the test harness (preinstalled on macOS and Linux).- Three pinned packages:
beautifulsoup4==4.15.0,requests==2.34.2,pytest==9.1.1. Seerequirements/README.md.
Free and open-source options
Every tool here is free and open source, and there is no paid tier of anything you are asked to install:
| Tool | Licence | Role |
|---|---|---|
| Python + standard library | PSF | the fixture server, robots.txt parsing, caching, CSV |
| beautifulsoup4 | MIT | the HTML parser |
| requests | Apache 2.0 | the HTTP client |
| pytest | MIT | the test runner |
The lesson's Alternatives section also covers lxml, selectolax, Scrapy and
Playwright. All four are free and open source too; none is installed here, and
the lesson says so rather than quoting output it did not produce.
Installation
cd labs/sections/programming-with-python/day-079-web-scraping-responsibly
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
That install is the only step that uses the internet. The tests themselves
need no network: the only server involved is the one this lab starts on
127.0.0.1.
Check it:
.venv/bin/python3 -c "import bs4, requests; print(bs4.__version__, requests.__version__)"
Expect 4.15.0 2.34.2.
File structure
day-079-web-scraping-responsibly/
├── README.md
├── metadata.yml
├── troubleshooting.md
├── security.md
├── requirements/
│ ├── README.md
│ └── requirements.txt
├── examples/
│ ├── fixtures/ the fake site, served from 127.0.0.1
│ │ ├── robots.txt disallows /private/, declares Crawl-delay: 1
│ │ ├── sitemap.txt the machine-readable alternative to crawling
│ │ ├── index.html
│ │ ├── catalogue/page-1.html 4 items, and the disallowed link
│ │ ├── catalogue/page-2.html 4 items, one with no price element
│ │ ├── catalogue/page-3.html 4 items, the last page — no next link
│ │ ├── detour/page-1.html a next link pointing at another host
│ │ └── private/internal-notes.html served by the server, never requested
│ ├── fixture_server.py local server on an ephemeral port, logs every request
│ ├── catalogue_scraper.py the reference implementation
│ ├── regex_vs_parser.py the same page, two ways, different answers
│ └── demo.py the whole thing end to end
├── starter/
│ └── catalogue_scraper.py the same module with six exercises to write
├── tests/
│ ├── test_scraper.py 34 pytest tests
│ └── run_tests.sh the harness — 51 checks
└── expected-output/
├── FIELDS.md what every number means
├── test-run.txt a real harness run
├── sample-run.txt a real demo run
├── regex-vs-parser.txt a real comparison run
└── catalogue.csv the 12-row result
How to run
Read the site's rules first — as you would on a real site:
cat examples/fixtures/robots.txt
Check the server works on its own:
PYTHONPATH=examples .venv/bin/python3 examples/fixture_server.py
See why a parser beats a regular expression:
PYTHONPATH=examples .venv/bin/python3 examples/regex_vs_parser.py
Run the reference scraper end to end:
PYTHONPATH=examples .venv/bin/python3 examples/demo.py catalogue.csv
head -4 catalogue.csv
Then do the work. Open starter/catalogue_scraper.py and complete the six
exercises in order, running the suite against your module after each one:
SCRAPER_MODULE=starter .venv/bin/pytest tests -q
You start at 33 failed, 1 passed and finish at 34 passed.
Finally, the whole harness:
bash tests/run_tests.sh
The six exercises
- Permission.
RobotsPolicy.allowsandRobotsPolicy.crawl_delay_seconds— one line each, on top ofurllib.robotparser.RobotsPolicy.loadis already written as a worked model. - Extract the table.
parse_items—soup.select("table.catalogue tr.item")and oneItemper row. - Survive the missing element.
_cell_text—select_one, aNonecheck, an optional exclusion,get_text(" ", strip=True). - Pagination.
next_page_urland thescrape_catalogueloop — stop when the site says there is no next page, not after a fixed count. - The cache.
ResponseCache.getand.put— a hit, a miss, and a human-readableindex.json. - The CSV.
write_csv— Day 65's rules, with an empty field where a price is missing.
What the commands do
| Command | What it does |
|---|---|
cat examples/fixtures/robots.txt |
shows the site's published rules: /private/ disallowed for everyone, Crawl-delay: 1, GreedyBot refused entirely, and a sitemap |
python3 examples/fixture_server.py |
starts the fixture server, fetches its own robots.txt once, prints the access log, and stops |
python3 examples/regex_vs_parser.py |
runs a naive regular expression and BeautifulSoup over the same three pages and prints both answers side by side |
python3 examples/demo.py catalogue.csv |
the full pipeline: parse robots, sort links by permission, crawl cold, crawl warm, write the CSV, print what the server saw |
SCRAPER_MODULE=starter pytest tests -q |
runs the same 34 tests against your module instead of the reference one |
bash tests/run_tests.sh |
the outer harness: 51 checks including the offline audit, the zero-requests assertion, and the proof that the starter's exercises are load-bearing |
Expected output
Real captures are in expected-output/, and expected-output/FIELDS.md
explains every number. The lines worth predicting before you run anything:
3. First run — cold cache
pages requested: 3 ['/catalogue/page-1.html', '/catalogue/page-2.html', '/catalogue/page-3.html']
items found: 12
items with no price cell: ['NAV-003'] — handled, not crashed
4. Second run — warm cache
requests this run: ['/robots.txt']
page requests this run: 0 []
6. What the server saw, across every run above
requests for /private/internal-notes.html: 0
And from the harness:
51 checks, 0 failure(s).
One line in expected-output/test-run.txt legitimately differs on your
machine — the two ephemeral port numbers. That is the check passing.
Validation steps
bash tests/run_tests.shexits 0 and prints51 checks, 0 failure(s).- Section 4 of that output says the disallowed path was requested zero times, per the server's own log.
- Section 7 says a second run makes zero page requests.
SCRAPER_MODULE=starter .venv/bin/pytest tests -qreports34 passedonce you have finished the six exercises.catalogue.csvhas 13 lines, and theNAV-003row's price field is empty rather than containing the wordNone:grep '^NAV-003' catalogue.csv- Nothing in
examples/,starter/ortests/contains an absolute URL with a hostname in it — the harness checks this for you in section 1.
Tests
tests/run_tests.sh is the outer harness and the thing to run. It performs 51
checks in nine sections:
- Offline by construction — no hostname-bearing URL in the lab's source, the one documentation-reserved address accounted for, port 0 confirmed, and two servers demonstrated to get two different ports.
- The tools — pytest,
bs4,requestspresent and at the pinned versions. - The reference suite —
pytest tests -qreports 34 passed. - Ethics implemented, not described — the disallowed page really is linked, robots.txt really does disallow it, and the server's log shows zero requests for it. Per-User-Agent rules are checked too.
- The messy realities — all five awkward rows parse correctly.
- Regex versus parser — 10 names against 12, with an undecoded entity.
- The cache — zero page requests on a second run, three hits, identical results, no crawl delay paid on a hit.
- End to end — the demo runs and its CSV is correct, including the empty price field and the surviving ampersand.
- The starter's exercises are load-bearing — the same suite fails against
the unfinished starter, with
NotImplementedErrorrather than an import error.
Nothing in any of that opens a socket to any address other than 127.0.0.1.
Cleanup
rm -f catalogue.csv
rm -rf .venv
git checkout -- starter/ # optional: reset your work
The response cache lives in a temporary directory created with mkdtemp and
is removed when the demo ends, so there is nothing else to tidy. No process is
left running: the fixture server stops in a finally block.
Troubleshooting
See troubleshooting.md. The three you are most likely to hit:
ModuleNotFoundError: No module named 'bs4'— you installbeautifulsoup4and importbs4. Different names, same package.AttributeError: 'NoneType' object has no attribute 'get_text'— Exercise 3, working as intended.select_onereturnedNone.- A proxy error on a loopback address — set
NO_PROXY=127.0.0.1for the run.
Security notes
See security.md. In short: the server binds loopback only, nothing
authenticates, nothing is sent anywhere, robots.txt is honoured because a
client chooses to and never because it is enforced, and everything you extract
from HTML is untrusted input until you validate it. The lab's fixture data
contains no people, deliberately.
Extension exercises
- Use the sitemap instead of crawling.
robots.txtnames/sitemap.txt. Fetch it, scrape only the pages it lists, and compare the result with the crawl. Then add a fourth catalogue page to the fixtures and see which of the two approaches finds it without a code change. - Make the cache expire. Store a timestamp beside each cached body and
treat anything older than an hour as a miss. Then explain, in a comment,
why you would not apply the same rule to
robots.txt. - Add a rate limiter that survives restarts. The current crawl delay is per-process. Record the time of the last request in a file so that two consecutive runs of your program still respect one request per second between them.
- Break the site and watch your scraper fail well. Change
td.pricetotd.costin one fixture page. Your scraper should report which page and which row it could not read, not silently write a CSV of empty prices. Add a check that fails loudly if more than half the rows are missing a field — that check is what tells you a site redesigned overnight. - Refuse to scrape at all. Write a small function that takes a base URL and returns a recommendation: use the API, use the sitemap, use the feed, or scrape. Feed it the fixture site and have it recommend the sitemap.
- Prove the negative differently. The harness proves the disallowed path
was never requested by reading the server's log. Add a second, independent
proof: wrap the
requests.Sessionso it records every URL it is asked for, and assert that list too. Two independent measurements of the same claim is how you catch a test that is lying to you.
Navigation
- Previous lab:
../day-078-http-and-requests/ - Next lab:
../day-080-building-clis-with-argparse/ - Section index:
../README.md - Week 12 project:
../projects/week-12/
Expected output
FIELDS.md
# Expected output — Day 079 lab
Every file here is a real capture from the authoring machine (macOS 26.5.1,
Apple Silicon, Python 3.14.0, beautifulsoup4 4.15.0, requests 2.34.2,
pytest 9.1.1, bash 3.2.57, 2026-07-19). Nothing in this lab reaches the
internet: the only server involved is started by the lab itself, listens on
127.0.0.1, and serves the files in `examples/fixtures/`.
## Files
- `test-run.txt` — a full `bash tests/run_tests.sh`: 51 checks, 0 failures,
exit 0.
- `sample-run.txt` — `python3 examples/demo.py catalogue.csv` end to end:
robots.txt parsed, links sorted by permission, a cold-cache crawl, a
warm-cache re-run, the CSV, and the server's own access log.
- `regex-vs-parser.txt` — `python3 examples/regex_vs_parser.py`: the naive
regular expression finds 10 names, three of them wrong or unusable;
BeautifulSoup finds all 12 correctly.
- `catalogue.csv` — the 12-row CSV the demo writes. Line endings are CRLF,
which is what the `csv` module produces by default and what RFC 4180 asks
for.
## The one line that changes between runs
`test-run.txt` contains, in section 1:
```
ok: two fixture servers get two different ephemeral ports ( 55118 55119 )
```
Those two numbers are **assigned by the operating system at run time** and will
differ on your machine and between your own runs. That is the check passing,
not the check drifting: the whole point is that the lab never claims a fixed
port such as 8000 and therefore never collides with anything you already have
running. Every other line in every file here is byte-identical run to run.
## What the numbers mean
| Number | Where it comes from |
| --- | --- |
| 12 items | 4 rows on each of the 3 catalogue pages |
| 1 item with no price | `NAV-003 Brass Compass` on page 2 has no `td.price` element at all |
| 3 page requests, first run | `/catalogue/page-1.html`, `-2`, `-3`; the crawl stops because page 3 has no `a.next` |
| 0 page requests, second run | every page came from the on-disk cache |
| 1 request on the second run | `/robots.txt`, deliberately never cached — permission is re-checked, never remembered |
| 0 requests for `/private/internal-notes.html` | robots.txt disallows it; the link is present on page 1 and the server would serve it |
| `[1.0, 1.0, 1.0]` sleeps | the `Crawl-delay: 1` declared in the fixture robots.txt, one per real fetch |
| 10 vs 12 names | the naive regex misses 2 rows outright and mangles 2 more |
| 34 passed | the pytest suite in `tests/test_scraper.py` against `examples/` |
| 1 passed on the starter | `RobotsPolicy.load` is left worked as a model; the six exercises are not |
## Two User-Agent strings in the demo's closing log
`sample-run.txt` ends with two distinct User-Agent strings. That is expected.
Section 1 of the demo loads robots.txt a second time under the name
`GreedyBot` in order to show that a site can publish different rules for
different clients, and the fixture robots.txt refuses `GreedyBot` everything.
Your own scraper sends exactly one User-Agent, and the harness asserts that
separately.
## Platform notes
- macOS and Linux produce identical bytes. The lab uses only `http.server`,
`threading` and `socket` behaviour that is the same on both.
- On Windows, run everything inside WSL. The captured files contain em dashes
and a `&` decoded from `&`; a terminal set to a legacy code page may
render those as replacement characters while the data itself stays correct.
- `catalogue.csv` has CRLF line endings. `wc -l` still reports 13; a text
editor set to strict LF may show a trailing `^M` on each line. Both are the
file being correct.
- If your machine has an HTTP proxy configured through `HTTP_PROXY` or
`ALL_PROXY`, `requests` may try to send even 127.0.0.1 traffic through it.
`troubleshooting.md` covers the fix.
catalogue.csv
sku,name,price,stock,source_path
NAV-001,Brass Sextant,42.00,7,/catalogue/page-1.html
NAV-002,Mariner Astrolabe,128.50,2,/catalogue/page-1.html
STA-001,Ink & Quill Set,12.75,31,/catalogue/page-1.html
STA-002,Vellum Notebook,9.99,18,/catalogue/page-1.html
NAV-003,Brass Compass,,4,/catalogue/page-2.html
STA-003,Sealing Wax Sticks,4.20,64,/catalogue/page-2.html
TOO-001,Bookbinder's Awl,6.50,12,/catalogue/page-2.html
TOO-002,Bone Folder,5.25,23,/catalogue/page-2.html
TOO-003,Linen Thread Spool,3.80,45,/catalogue/page-3.html
MAP-001,Coastal Chart Portfolio,88.00,3,/catalogue/page-3.html
MAP-002,Star Atlas,64.40,5,/catalogue/page-3.html
MAP-003,Harbour Plan Set,27.15,9,/catalogue/page-3.html
regex-vs-parser.txt
regular expression found: 10 names
'Brass Sextant'
'Ink & Quill Set'
'Brass Compass'
'Sealing Wax Sticks'
"Bookbinder's Awl"
'Bone Folder'
'\n Linen Thread Spool\n '
'Coastal Chart Portfolio'
'Star Atlas'
'Harbour Plan Set'
BeautifulSoup found: 12 names
'Brass Sextant'
'Mariner Astrolabe'
'Ink & Quill Set'
'Vellum Notebook'
'Brass Compass'
'Sealing Wax Sticks'
"Bookbinder's Awl"
'Bone Folder'
'Linen Thread Spool'
'Coastal Chart Portfolio'
'Star Atlas'
'Harbour Plan Set'
missed by the regular expression: 4
'Mariner Astrolabe'
'Ink & Quill Set'
'Vellum Notebook'
'Linen Thread Spool'
Three different failures, all on valid HTML:
1. class="name featured" — a second class defeats the literal match
2. a nested <span> inside the cell — [^<]* stops at the '<'
3. & is returned raw; the parser decodes it to '&'
sample-run.txt
fixture server: 127.0.0.1 on an ephemeral port
User-Agent: HarbourCatalogueLab/1.0 (course exercise; contact: scraper-owner@example.com)
note: the crawl delay is recorded rather than slept, so this
demo finishes instantly; a real run waits the full
delay before every fetch that is not a cache hit.
1. Permission
allowed /catalogue/page-1.html -> True
allowed /private/internal-notes.html -> False
crawl delay declared -> 1.0 s
allowed for GreedyBot -> False
2. Links on page 1, sorted by permission
allowed: /catalogue/page-2.html
REFUSED: /private/internal-notes.html
fetching it anyway raises DisallowedByRobots on /private/internal-notes.html
3. First run — cold cache
pages requested: 3 ['/catalogue/page-1.html', '/catalogue/page-2.html', '/catalogue/page-3.html']
cache misses: 3, hits: 0
sleeps asked for: [1.0, 1.0, 1.0] (seconds, from Crawl-delay)
items found: 12
sku price name
NAV-001 42.00 Brass Sextant
NAV-002 128.50 Mariner Astrolabe
STA-001 12.75 Ink & Quill Set
STA-002 9.99 Vellum Notebook
NAV-003 (none) Brass Compass
STA-003 4.20 Sealing Wax Sticks
TOO-001 6.50 Bookbinder's Awl
TOO-002 5.25 Bone Folder
TOO-003 3.80 Linen Thread Spool
MAP-001 88.00 Coastal Chart Portfolio
MAP-002 64.40 Star Atlas
MAP-003 27.15 Harbour Plan Set
items with no price cell: ['NAV-003'] — handled, not crashed
4. Second run — warm cache
requests this run: ['/robots.txt']
page requests this run: 0 []
cache hits: 3, misses: 0
same items as the first run: True
5. Output
wrote catalogue.csv (13 lines including the header)
| sku,name,price,stock,source_path
| NAV-001,Brass Sextant,42.00,7,/catalogue/page-1.html
| NAV-002,Mariner Astrolabe,128.50,2,/catalogue/page-1.html
| STA-001,Ink & Quill Set,12.75,31,/catalogue/page-1.html
...
| MAP-003,Harbour Plan Set,27.15,9,/catalogue/page-3.html
6. What the server saw, across every run above
total requests: 7
requests for /private/internal-notes.html: 0
User-Agent seen: GreedyBot
User-Agent seen: HarbourCatalogueLab/1.0 (course exercise; contact: scraper-owner@example.com)
test-run.txt
Day 079 — Scrape a Site You Are Allowed To
1. Offline by construction
ok: no hostname-bearing URL anywhere in examples/, starter/ or tests/
ok: the only off-site link is a documentation-reserved address (198.51.100.0/24)
ok: the fixture server binds port 0 and reads back the assigned port
ok: two fixture servers get two different ephemeral ports ( 55118 55119 )
2. The tools
ok: pytest --version reports a pytest ( pytest 9.1.1 )
ok: beautifulsoup4 imports as bs4 ( 4.15.0 )
ok: requests is importable ( 2.34.2 )
3. The reference suite
ok: pytest tests exits 0 against examples/
ok: the reference suite reports 34 passed
4. Ethics implemented, not described
ok: the fixture really does link the disallowed page from page 1
ok: robots.txt really does disallow /private/ for every User-Agent
ok: the disallowed path was requested ZERO times, per the server's own log
ok: pagination followed all 3 pages and stopped
ok: the crawl found all 12 items
ok: exactly one User-Agent string was sent — the client identified itself
ok: robots.txt rules are per-User-Agent ( polite True greedy False delay 1.0 )
5. The messy realities
ok: the item with no price cell yields price=None and does not shorten the page
ok: the HTML entity & is decoded to '&'
ok: the decorative nested tag is kept out of the name
ok: a cell with two classes is still found by td.name
ok: whitespace-wrapped text is stripped
6. The regular expression fails where the parser does not
ok: examples/regex_vs_parser.py exits 0
ok: the naive regex finds only 10 of the 12 names
ok: the parser finds all 12
ok: the regex hands back an undecoded HTML entity
7. The cache means a re-run costs the site nothing
ok: a second run makes ZERO page requests
ok: all three pages came from the cache
ok: the cached run produces byte-identical items
ok: a cache hit does not pay the crawl delay
ok: the cache holds one file per page fetched
8. End to end: the demo and its CSV
ok: examples/demo.py exits 0
ok: the demo's closing server log shows zero requests for the disallowed path
ok: the demo's second run makes no page requests
ok: the CSV has 13 lines: a header and 12 items
ok: the CSV header names the five columns
ok: the CSV uses RFC 4180 CRLF line endings, as the csv module defaults to
ok: the price-less item writes an EMPTY field, not the word None
ok: the ampersand survives HTML, parsing and CSV
ok: the CSV contains no literal 'None'
9. The starter's exercises are load-bearing
ok: the same suite FAILS against the unfinished starter (exit 1)
ok: the starter fails on its own NotImplementedError, not on an import error
ok: exactly one test passes on the starter — the worked RobotsPolicy.load
ok: the starter names Exercise 1a
ok: the starter names Exercise 1b
ok: the starter names Exercise 2
ok: the starter names Exercise 3
ok: the starter names Exercise 4a
ok: the starter names Exercise 4b
ok: the starter names Exercise 5a
ok: the starter names Exercise 5b
ok: the starter names Exercise 6
51 checks, 0 failure(s).
Source files
examples/catalogue_scraper.py (14030 bytes)
"""A polite, cached, parser-based scraper for the fixture catalogue.
This is the reference implementation. ``starter/catalogue_scraper.py`` is the
same file with six numbered exercises hollowed out; the same test suite runs
against both, which is how you know the exercises are load-bearing.
The shape of the module is the lesson in code, in this order:
1. ``RobotsPolicy`` — permission, decided BEFORE any page is fetched.
2. ``ResponseCache`` — a page fetched once is never fetched again.
3. ``fetch_text`` — the only function that is allowed to make a request.
4. ``parse_items`` — pure: HTML in, ``Item`` objects out, no network.
5. ``next_page_url`` — pure: finds the pagination link, or ``None`` at the end.
6. ``scrape_catalogue`` / ``write_csv`` — the loop and the output.
Every boundary — the network session, the clock, the cache directory — is a
parameter rather than a global. That is Day 74's rule, and it is the reason
the tests can run this scraper with a fake clock and count the sleeps it asked
for without ever waiting a real second.
"""
from __future__ import annotations
import csv
import hashlib
import json
import time
import urllib.robotparser
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterable, Sequence
from urllib.parse import urljoin, urlparse
import requests
from bs4 import BeautifulSoup
# Identify yourself honestly, and leave a way to be contacted. A site owner
# who can see who you are and reach you will usually ask you to slow down
# before they block you. `example.com` is reserved by the IETF for
# documentation, so this address belongs to nobody; put a real one in a real
# scraper.
USER_AGENT = "HarbourCatalogueLab/1.0 (course exercise; contact: scraper-owner@example.com)"
DEFAULT_TIMEOUT = 10.0
FALLBACK_DELAY_SECONDS = 1.0
class ScrapeError(Exception):
"""Base class for everything this module refuses to do."""
class DisallowedByRobots(ScrapeError):
"""The path is disallowed by robots.txt for our User-Agent."""
class OffSite(ScrapeError):
"""The URL points somewhere other than the site we were asked to scrape."""
# ---------------------------------------------------------------------------
# 1. Permission
# ---------------------------------------------------------------------------
@dataclass
class RobotsPolicy:
"""A parsed robots.txt, and the two questions worth asking it.
Built with :meth:`load` so that the fetch of robots.txt itself goes through
our own session with our own User-Agent. ``RobotFileParser.read()`` would
also work, but it opens its own connection with urllib's default header,
which means the site cannot tell it was us.
"""
parser: urllib.robotparser.RobotFileParser
user_agent: str
@classmethod
def load(
cls,
base_url: str,
*,
session: requests.Session,
user_agent: str = USER_AGENT,
timeout: float = DEFAULT_TIMEOUT,
) -> "RobotsPolicy":
robots_url = urljoin(base_url, "/robots.txt")
parser = urllib.robotparser.RobotFileParser()
parser.set_url(robots_url)
response = session.get(
robots_url, headers={"User-Agent": user_agent}, timeout=timeout
)
if response.status_code == 200:
parser.parse(response.text.splitlines())
elif response.status_code in (401, 403):
# RFC 9309: an authentication or forbidden status means the whole
# site is off limits. Treat it that way rather than shrugging.
parser.disallow_all = True
else:
# 404 and other 4xx mean "no rules published", which permits all.
parser.allow_all = True
return cls(parser=parser, user_agent=user_agent)
def allows(self, url: str) -> bool:
"""Is this exact URL allowed for our User-Agent?"""
return self.parser.can_fetch(self.user_agent, url)
def crawl_delay_seconds(self, default: float = FALLBACK_DELAY_SECONDS) -> float:
"""Seconds to wait between requests, per robots.txt, else ``default``.
Note that ``RobotFileParser`` only accepts whole-number crawl delays;
a fractional value in robots.txt is ignored and you get ``None`` here.
"""
declared = self.parser.crawl_delay(self.user_agent)
return float(declared) if declared is not None else default
# ---------------------------------------------------------------------------
# 2. The cache
# ---------------------------------------------------------------------------
@dataclass
class ResponseCache:
"""Response bodies stored on disk, keyed by URL.
The point is not speed, though it is faster. The point is that while you
are getting the parser right — and you will iterate on the parser twenty
times — the site is asked for each page exactly once. Re-running your
program should cost the person who owns that server nothing.
"""
directory: Path
hits: int = 0
misses: int = 0
def __post_init__(self) -> None:
self.directory = Path(self.directory)
self.directory.mkdir(parents=True, exist_ok=True)
def _path_for(self, url: str) -> Path:
digest = hashlib.sha256(url.encode("utf-8")).hexdigest()[:32]
return self.directory / f"{digest}.html"
def get(self, url: str) -> str | None:
path = self._path_for(url)
if path.exists():
self.hits += 1
return path.read_text(encoding="utf-8")
self.misses += 1
return None
def put(self, url: str, text: str) -> None:
self._path_for(url).write_text(text, encoding="utf-8")
# A human-readable map, so a cache directory is inspectable rather
# than being 32 hex characters of mystery.
index_path = self.directory / "index.json"
index: dict[str, str] = {}
if index_path.exists():
index = json.loads(index_path.read_text(encoding="utf-8"))
index[self._path_for(url).name] = url
index_path.write_text(json.dumps(index, indent=2, sort_keys=True), encoding="utf-8")
# ---------------------------------------------------------------------------
# 3. The only function allowed to make a request
# ---------------------------------------------------------------------------
def fetch_text(
url: str,
*,
session: requests.Session,
robots: RobotsPolicy,
cache: ResponseCache | None = None,
sleeper: Callable[[float], None] = time.sleep,
delay_seconds: float | None = None,
user_agent: str = USER_AGENT,
timeout: float = DEFAULT_TIMEOUT,
) -> str:
"""Return the body of ``url``, from cache if possible, politely if not.
Raises :class:`DisallowedByRobots` before any socket is opened if the path
is refused. The order matters: check, then cache, then sleep, then fetch.
"""
if not robots.allows(url):
raise DisallowedByRobots(url)
if cache is not None:
cached = cache.get(url)
if cached is not None:
return cached
# Only a real fetch pays the crawl delay. A cache hit costs the site
# nothing, so waiting for it would be politeness theatre.
wait = robots.crawl_delay_seconds() if delay_seconds is None else delay_seconds
if wait > 0:
sleeper(wait)
response = session.get(url, headers={"User-Agent": user_agent}, timeout=timeout)
response.raise_for_status()
text = response.text
if cache is not None:
cache.put(url, text)
return text
# ---------------------------------------------------------------------------
# 4. Parsing — pure functions, no network
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Item:
"""One catalogue row. ``price`` is ``None`` when the page omits the cell."""
sku: str
name: str
price: float | None
stock: int | None
source_path: str
def _cell_text(row, selector: str, *, drop: str | None = None) -> str | None:
"""Text of the first cell matching ``selector``, or ``None`` if absent.
Three habits live in these few lines and all three matter more than they
look.
``select_one`` returns ``None`` rather than raising, so the caller decides
what a missing cell means — this is the single most common scraping bug,
and the fix is to never chain ``.text`` onto a lookup you have not checked.
``get_text(" ", strip=True)`` collapses the whitespace that HTML authors
put around text for readability, and BeautifulSoup has already turned
``&`` back into ``&`` by this point.
``drop`` removes elements you do not want swept up. ``get_text`` returns
the text of *every* descendant, so a decorative ``<span class="badge">new</span>``
inside a name cell silently becomes part of the product name unless you
say otherwise. Naming your exclusions is cheaper than explaining later why
forty rows are called "Something new".
"""
cell = row.select_one(selector)
if cell is None:
return None
if drop:
for extra in cell.select(drop):
extra.extract()
return cell.get_text(" ", strip=True)
def parse_items(html: str, *, source_path: str = "") -> list[Item]:
"""Extract every catalogue row from one page.
``tr.item`` matches rows whose class *contains* ``item``, which is why the
``class="name featured"`` cell below is found by ``td.name`` while the
obvious regular expression for ``class="name"`` misses it entirely.
"""
soup = BeautifulSoup(html, "html.parser")
items: list[Item] = []
for row in soup.select("table.catalogue tr.item"):
sku = row.get("data-sku", "")
name = _cell_text(row, "td.name", drop="span.badge") or ""
price_text = _cell_text(row, "td.price")
stock_text = _cell_text(row, "td.stock")
items.append(
Item(
sku=sku,
name=name,
price=float(price_text) if price_text else None,
stock=int(stock_text) if stock_text else None,
source_path=source_path,
)
)
return items
def next_page_url(html: str, current_url: str) -> str | None:
"""Absolute URL of the next page, or ``None`` when this was the last one.
The last page carries ``<span class="next disabled">`` instead of a link,
so ``a.next`` finds nothing and the loop stops. Do not stop on an item
count or a page number — pages get added.
"""
soup = BeautifulSoup(html, "html.parser")
link = soup.select_one("nav.pager a.next")
if link is None:
return None
href = link.get("href")
if not href:
return None
return urljoin(current_url, href)
# ---------------------------------------------------------------------------
# 5. The loop
# ---------------------------------------------------------------------------
def scrape_catalogue(
start_url: str,
*,
session: requests.Session,
robots: RobotsPolicy,
cache: ResponseCache | None = None,
sleeper: Callable[[float], None] = time.sleep,
delay_seconds: float | None = None,
max_pages: int = 50,
) -> list[Item]:
"""Follow the pagination from ``start_url`` and return every item found.
``max_pages`` and the ``seen`` set are not decoration. A pagination loop
that points back at itself is common enough that an unbounded crawler will
eventually hammer somebody's server until they block you.
"""
items: list[Item] = []
seen: set[str] = set()
url: str | None = start_url
origin = urlparse(start_url)
while url is not None and len(seen) < max_pages:
if url in seen:
break
if urlparse(url).netloc != origin.netloc:
raise OffSite(url)
seen.add(url)
html = fetch_text(
url,
session=session,
robots=robots,
cache=cache,
sleeper=sleeper,
delay_seconds=delay_seconds,
)
items.extend(parse_items(html, source_path=urlparse(url).path))
url = next_page_url(html, url)
return items
def follow_links_politely(
html: str,
base_url: str,
*,
robots: RobotsPolicy,
) -> tuple[list[str], list[str]]:
"""Split every link on a page into (allowed, refused) by robots.txt.
This is the function the harness uses to prove the ethics were implemented
rather than described: the disallowed link is present in the HTML, it is
discovered, and it is put in the refused list instead of being requested.
"""
soup = BeautifulSoup(html, "html.parser")
allowed: list[str] = []
refused: list[str] = []
for anchor in soup.select("a[href]"):
target = urljoin(base_url, anchor["href"])
(allowed if robots.allows(target) else refused).append(target)
return allowed, refused
# ---------------------------------------------------------------------------
# 6. Output
# ---------------------------------------------------------------------------
CSV_COLUMNS: Sequence[str] = ("sku", "name", "price", "stock", "source_path")
def write_csv(items: Iterable[Item], path: Path) -> Path:
"""Write items to ``path`` as CSV, using Day 65's rules.
``newline=""`` is required by the csv module, a missing price becomes an
empty field rather than the string ``None``, and the file is UTF-8 so the
ampersand-and-friends survive.
"""
path = Path(path)
with path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.writer(handle)
writer.writerow(CSV_COLUMNS)
for item in items:
writer.writerow(
[
item.sku,
item.name,
"" if item.price is None else f"{item.price:.2f}",
"" if item.stock is None else item.stock,
item.source_path,
]
)
return path
examples/demo.py (6652 bytes)
"""The whole lab end to end, against the local fixture server.
Run it from the lab directory::
PYTHONPATH=examples python3 examples/demo.py catalogue.csv
What you will see, in order:
1. robots.txt fetched and parsed, and the two questions asked of it.
2. Every link on page 1 sorted into allowed and refused — the refused one is
never requested, and the server's own log at the end proves it.
3. A first scraping run: three pages fetched, twelve items found, one of them
with no price at all.
4. A second run with the same cache: zero page requests. Only robots.txt is
re-fetched, because permission is a thing you re-check, not a thing you
cache.
5. The CSV.
6. The server's access log, which is the honest record of what we did.
Nothing here reaches the internet. The server is on 127.0.0.1 on a port the
operating system picked, and it stops when this script does.
"""
from __future__ import annotations
import shutil
import sys
import tempfile
from pathlib import Path
import requests
from catalogue_scraper import (
USER_AGENT,
DisallowedByRobots,
ResponseCache,
RobotsPolicy,
fetch_text,
follow_links_politely,
scrape_catalogue,
write_csv,
)
from fixture_server import serve_fixtures
def main(argv: list[str]) -> int:
csv_path = Path(argv[1]) if len(argv) > 1 else Path("catalogue.csv")
cache_dir = Path(tempfile.mkdtemp(prefix="scrape-cache-"))
recorded_sleeps: list[float] = []
with serve_fixtures() as site, requests.Session() as session:
print("fixture server: 127.0.0.1 on an ephemeral port")
print(f"User-Agent: {USER_AGENT}")
print("note: the crawl delay is recorded rather than slept, so this")
print(" demo finishes instantly; a real run waits the full")
print(" delay before every fetch that is not a cache hit.")
print()
# 1 -----------------------------------------------------------------
print("1. Permission")
robots = RobotsPolicy.load(site.base_url, session=session)
start = site.url_for("/catalogue/page-1.html")
private = site.url_for("/private/internal-notes.html")
print(f" allowed /catalogue/page-1.html -> {robots.allows(start)}")
print(f" allowed /private/internal-notes.html -> {robots.allows(private)}")
print(f" crawl delay declared -> {robots.crawl_delay_seconds()} s")
greedy = RobotsPolicy.load(site.base_url, session=session, user_agent="GreedyBot")
print(f" allowed for GreedyBot -> {greedy.allows(start)}")
print()
# 2 -----------------------------------------------------------------
print("2. Links on page 1, sorted by permission")
first_page = fetch_text(
start,
session=session,
robots=robots,
sleeper=recorded_sleeps.append,
)
allowed, refused = follow_links_politely(first_page, start, robots=robots)
for url in allowed:
print(f" allowed: {url.removeprefix(site.base_url)}")
for url in refused:
print(f" REFUSED: {url.removeprefix(site.base_url)}")
try:
fetch_text(private, session=session, robots=robots, sleeper=recorded_sleeps.append)
except DisallowedByRobots as error:
print(f" fetching it anyway raises DisallowedByRobots on {str(error).removeprefix(site.base_url)}")
print()
# 3 -----------------------------------------------------------------
print("3. First run — cold cache")
site.reset()
cache = ResponseCache(cache_dir)
items = scrape_catalogue(
start,
session=session,
robots=robots,
cache=cache,
sleeper=recorded_sleeps.append,
)
print(f" pages requested: {len(site.paths())} {site.paths()}")
print(f" cache misses: {cache.misses}, hits: {cache.hits}")
print(f" sleeps asked for: {recorded_sleeps[-3:]} (seconds, from Crawl-delay)")
print(f" items found: {len(items)}")
print()
print(" sku price name")
for item in items:
price = " (none)" if item.price is None else f"{item.price:>7.2f}"
print(f" {item.sku:<8} {price} {item.name}")
missing = [item.sku for item in items if item.price is None]
print(f" items with no price cell: {missing} — handled, not crashed")
print()
# 4 -----------------------------------------------------------------
print("4. Second run — warm cache")
site.reset()
# Permission is re-checked every run. robots.txt is the one thing we
# deliberately do NOT cache: the site owner may have changed their mind
# since yesterday, and a cached permission is a stale permission.
robots_again = RobotsPolicy.load(site.base_url, session=session)
cache2 = ResponseCache(cache_dir)
again = scrape_catalogue(
start,
session=session,
robots=robots_again,
cache=cache2,
sleeper=recorded_sleeps.append,
)
page_requests = [path for path in site.paths() if path != "/robots.txt"]
print(f" requests this run: {site.paths()}")
print(f" page requests this run: {len(page_requests)} {page_requests}")
print(f" cache hits: {cache2.hits}, misses: {cache2.misses}")
print(f" same items as the first run: {again == items}")
print()
# 5 -----------------------------------------------------------------
print("5. Output")
write_csv(items, csv_path)
text = csv_path.read_text(encoding="utf-8")
print(f" wrote {csv_path} ({len(text.splitlines())} lines including the header)")
for line in text.splitlines()[:4]:
print(f" | {line}")
print(" ...")
for line in text.splitlines()[-1:]:
print(f" | {line}")
print()
# 6 -----------------------------------------------------------------
print("6. What the server saw, across every run above")
print(f" total requests: {len(site.all_paths())}")
print(f" requests for /private/internal-notes.html: {site.total_count('/private/internal-notes.html')}")
for agent in sorted(site.all_user_agents()):
print(f" User-Agent seen: {agent}")
shutil.rmtree(cache_dir, ignore_errors=True)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
examples/fixture_server.py (6297 bytes)
"""A local fixture web server that counts every request it receives.
Nothing in this lab talks to the internet. Instead we serve the fake catalogue
in ``examples/fixtures/`` over real HTTP from 127.0.0.1, so the scraper you
write is a real scraper making real requests — it just cannot reach anyone
else's machine.
Two details make this useful rather than merely offline:
* The port is **ephemeral**. We bind port 0 and read back whatever the
operating system assigned. Hard-coding 8000 collides with whatever the
learner already has running, and a lab that fails because of a port clash
teaches nothing.
* Every request is **recorded**. ``FixtureServer.requests`` is the server's own
log, not the client's. That is what lets the test suite prove a negative:
the path robots.txt disallows was never even asked for. A scraper that
merely *says* it honours robots.txt cannot fake this.
Standard library only: ``http.server``, ``socketserver``, ``threading``.
"""
from __future__ import annotations
import threading
from contextlib import contextmanager
from dataclasses import dataclass, field
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Iterator
FIXTURES = Path(__file__).resolve().parent / "fixtures"
@dataclass
class RequestRecord:
"""One line of the server's access log."""
method: str
path: str
user_agent: str
@dataclass
class FixtureServer:
"""A running server plus the log of everything it was asked for."""
host: str
port: int
requests: list[RequestRecord] = field(default_factory=list)
history: list[RequestRecord] = field(default_factory=list)
@property
def base_url(self) -> str:
"""The root URL of this run, e.g. ``http://127.0.0.1:54321``."""
return f"http://{self.host}:{self.port}"
def url_for(self, path: str) -> str:
"""Absolute URL for a site-root-relative path such as ``/robots.txt``."""
return self.base_url + path
def paths(self) -> list[str]:
"""Every path requested, in order."""
return [record.path for record in self.requests]
def count(self, path: str) -> int:
"""How many times ``path`` was requested. The ethics assertion uses this."""
return self.paths().count(path)
def user_agents(self) -> set[str]:
"""The distinct User-Agent strings seen. An honest client sends one."""
return {record.user_agent for record in self.requests}
def all_paths(self) -> list[str]:
"""Every path requested since the server started, across all resets."""
return [record.path for record in self.history] + self.paths()
def total_count(self, path: str) -> int:
"""Requests for ``path`` since the server started, ignoring resets."""
return self.all_paths().count(path)
def all_user_agents(self) -> set[str]:
"""Distinct User-Agent strings seen since the server started."""
return {record.user_agent for record in self.history} | self.user_agents()
def reset(self) -> None:
"""Start a fresh log so the next run can be measured on its own.
The old records move to ``history`` rather than being destroyed, so a
question like "was the disallowed path *ever* requested?" still has an
answer at the end of the program.
"""
self.history.extend(self.requests)
self.requests.clear()
def _make_handler(record_to: list[RequestRecord], directory: Path):
class RecordingHandler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(directory), **kwargs)
def _record(self, method: str) -> None:
record_to.append(
RequestRecord(
method=method,
path=self.path,
user_agent=self.headers.get("User-Agent", ""),
)
)
def do_GET(self) -> None: # noqa: N802 - name fixed by http.server
self._record("GET")
super().do_GET()
def do_HEAD(self) -> None: # noqa: N802 - name fixed by http.server
self._record("HEAD")
super().do_HEAD()
def log_message(self, fmt: str, *args) -> None:
"""Silence the default stderr access log; we keep our own."""
return RecordingHandler
@contextmanager
def serve_fixtures(directory: Path | None = None) -> Iterator[FixtureServer]:
"""Serve ``directory`` on 127.0.0.1 and an ephemeral port for the block.
Usage::
with serve_fixtures() as site:
html = requests.get(site.url_for("/catalogue/page-1.html")).text
assert site.count("/private/internal-notes.html") == 0
The server is shut down and joined on the way out, including when the body
raises, so a failing test never leaves a stray listener behind.
"""
directory = directory or FIXTURES
records: list[RequestRecord] = []
handler = _make_handler(records, directory)
# Port 0 means "any free port"; server_address tells us which one we got.
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
host, port = httpd.server_address[0], httpd.server_address[1]
# A short poll interval keeps shutdown() quick; the default of 0.5 seconds
# would add half a second to the teardown of every test that starts a
# server, which is the difference between a suite people run and one they
# skip.
thread = threading.Thread(
target=httpd.serve_forever, kwargs={"poll_interval": 0.02}, name="fixture-server"
)
thread.daemon = True
thread.start()
try:
yield FixtureServer(host=host, port=port, requests=records)
finally:
httpd.shutdown()
httpd.server_close()
thread.join(timeout=5)
if __name__ == "__main__": # A quick manual check of the server itself.
import urllib.request
with serve_fixtures() as site:
with urllib.request.urlopen(site.url_for("/robots.txt")) as response:
first_line = response.read().decode().splitlines()[0]
print("served from 127.0.0.1 on an ephemeral port")
print("first line of robots.txt:", first_line)
print("requests recorded:", site.paths())
examples/fixtures/catalogue/page-1.html (1360 bytes)
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Harbour Chandlery — catalogue, page 1</title>
</head>
<body>
<h1>Harbour Chandlery</h1>
<p class="note">Page 1 of 3. Prices in GBP.</p>
<table class="catalogue">
<thead>
<tr><th>Item</th><th>Price</th><th>In stock</th></tr>
</thead>
<tbody>
<tr class="item" data-sku="NAV-001">
<td class="name">Brass Sextant</td>
<td class="price">42.00</td>
<td class="stock">7</td>
</tr>
<tr class="item" data-sku="NAV-002">
<td class="name featured">Mariner Astrolabe</td>
<td class="price">128.50</td>
<td class="stock">2</td>
</tr>
<tr class="item" data-sku="STA-001">
<td class="name">Ink & Quill Set</td>
<td class="price">12.75</td>
<td class="stock">31</td>
</tr>
<tr class="item" data-sku="STA-002">
<td class="name">Vellum Notebook <span class="badge">new</span></td>
<td class="price">9.99</td>
<td class="stock">18</td>
</tr>
</tbody>
</table>
<nav class="pager">
<span class="prev disabled">Previous</span>
<a class="next" href="page-2.html">Next</a>
</nav>
<footer>
<p>Staff only: <a class="secret" href="/private/internal-notes.html">supplier notes</a></p>
</footer>
</body>
</html>
examples/fixtures/catalogue/page-2.html (1159 bytes)
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Harbour Chandlery — catalogue, page 2</title>
</head>
<body>
<h1>Harbour Chandlery</h1>
<p class="note">Page 2 of 3. Prices in GBP.</p>
<table class="catalogue">
<thead>
<tr><th>Item</th><th>Price</th><th>In stock</th></tr>
</thead>
<tbody>
<tr class="item" data-sku="NAV-003">
<td class="name">Brass Compass</td>
<td class="stock">4</td>
</tr>
<tr class="item" data-sku="STA-003">
<td class="name">Sealing Wax Sticks</td>
<td class="price">4.20</td>
<td class="stock">64</td>
</tr>
<tr class="item" data-sku="TOO-001">
<td class="name">Bookbinder's Awl</td>
<td class="price">6.50</td>
<td class="stock">12</td>
</tr>
<tr class="item" data-sku="TOO-002">
<td class="name">Bone Folder</td>
<td class="price">5.25</td>
<td class="stock">23</td>
</tr>
</tbody>
</table>
<nav class="pager">
<a class="prev" href="page-1.html">Previous</a>
<a class="next" href="page-3.html">Next</a>
</nav>
</body>
</html>
examples/fixtures/catalogue/page-3.html (1221 bytes)
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Harbour Chandlery — catalogue, page 3</title>
</head>
<body>
<h1>Harbour Chandlery</h1>
<p class="note">Page 3 of 3. Prices in GBP.</p>
<table class="catalogue">
<thead>
<tr><th>Item</th><th>Price</th><th>In stock</th></tr>
</thead>
<tbody>
<tr class="item" data-sku="TOO-003">
<td class="name">
Linen Thread Spool
</td>
<td class="price">3.80</td>
<td class="stock">45</td>
</tr>
<tr class="item" data-sku="MAP-001">
<td class="name">Coastal Chart Portfolio</td>
<td class="price">88.00</td>
<td class="stock">3</td>
</tr>
<tr class="item" data-sku="MAP-002">
<td class="name">Star Atlas</td>
<td class="price">64.40</td>
<td class="stock">5</td>
</tr>
<tr class="item" data-sku="MAP-003">
<td class="name">Harbour Plan Set</td>
<td class="price">27.15</td>
<td class="stock">9</td>
</tr>
</tbody>
</table>
<nav class="pager">
<a class="prev" href="page-2.html">Previous</a>
<span class="next disabled">Next</span>
</nav>
</body>
</html>
examples/fixtures/detour/page-1.html (913 bytes)
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Harbour Chandlery — sale, page 1</title>
</head>
<body>
<h1>Harbour Chandlery — clearance</h1>
<p class="note">
The "Next" link below points at a completely different host. Real
pagination links do this by accident all the time — a template variable
picks up the wrong base, a mirror is linked from a footer, a redirect
lands somewhere else. A crawler that follows it has quietly started
scraping a site it never checked robots.txt for.
</p>
<table class="catalogue">
<tbody>
<tr class="item" data-sku="CLR-001">
<td class="name">Frayed Mooring Line</td>
<td class="price">1.00</td>
<td class="stock">1</td>
</tr>
</tbody>
</table>
<nav class="pager">
<a class="next" href="http://198.51.100.7/catalogue/page-2.html">Next</a>
</nav>
</body>
</html>
examples/fixtures/index.html (369 bytes)
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Harbour Chandlery</title>
</head>
<body>
<h1>Harbour Chandlery</h1>
<p>A fixture site with no real existence, served from 127.0.0.1 for this lab.</p>
<ul>
<li><a href="/catalogue/page-1.html">Catalogue</a></li>
<li><a href="/robots.txt">robots.txt</a></li>
</ul>
</body>
</html>
examples/fixtures/private/internal-notes.html (762 bytes)
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Harbour Chandlery — supplier notes</title>
</head>
<body>
<h1>Supplier notes</h1>
<p>
This page sits under /private/, which robots.txt disallows for every
User-Agent. The fixture server will serve it to anyone who asks — refusing
to ask is entirely your scraper's job, which is the point of the exercise.
If a request for this path ever reaches the server during the test run,
the harness fails.
</p>
<table class="catalogue">
<tbody>
<tr class="item" data-sku="XXX-999">
<td class="name">Wholesale margin sheet</td>
<td class="price">0.00</td>
<td class="stock">0</td>
</tr>
</tbody>
</table>
</body>
</html>
examples/fixtures/robots.txt (528 bytes)
# robots.txt for the Harbour Chandlery fixture site.
#
# This file is served from the root of the local test server, exactly where a
# real site serves it. Everything below is standard Robots Exclusion Protocol
# (RFC 9309) and is parsed by urllib.robotparser without modification.
User-agent: *
Disallow: /private/
Crawl-delay: 1
# A named rule, so you can see that robotparser matches on the User-Agent
# string you pass to can_fetch. This one is refused everything.
User-agent: GreedyBot
Disallow: /
Sitemap: /sitemap.txt
examples/fixtures/sitemap.txt (69 bytes)
/catalogue/page-1.html
/catalogue/page-2.html
/catalogue/page-3.html
examples/regex_vs_parser.py (2151 bytes)
"""Why a parser beats a regular expression, demonstrated on valid HTML.
Run it::
python3 examples/regex_vs_parser.py
No server and no network: this reads the three fixture pages straight off the
disk. The regular expression below is not a straw man. It is exactly what a
careful person writes on their first attempt, it is anchored on the real
markup, and it works perfectly on the first row of the first page — which is
the trap. Every failure it produces here comes from HTML that is completely
valid and that a browser renders without complaint.
"""
from __future__ import annotations
import re
from pathlib import Path
from catalogue_scraper import parse_items
FIXTURES = Path(__file__).resolve().parent / "fixtures" / "catalogue"
PAGES = ["page-1.html", "page-2.html", "page-3.html"]
# The obvious regular expression: find a name cell, capture what is inside it.
NAME_RE = re.compile(r'<td class="name">([^<]*)</td>')
def regex_names(html: str) -> list[str]:
return [match.group(1) for match in NAME_RE.finditer(html)]
def main() -> None:
regex_found: list[str] = []
parser_found: list[str] = []
for page in PAGES:
html = (FIXTURES / page).read_text(encoding="utf-8")
regex_found.extend(regex_names(html))
parser_found.extend(item.name for item in parse_items(html, source_path=page))
print("regular expression found:", len(regex_found), "names")
for name in regex_found:
print(f" {name!r}")
print()
print("BeautifulSoup found:", len(parser_found), "names")
for name in parser_found:
print(f" {name!r}")
missed = [name for name in parser_found if name not in regex_found]
print()
print("missed by the regular expression:", len(missed))
for name in missed:
print(f" {name!r}")
print()
print("Three different failures, all on valid HTML:")
print(" 1. class=\"name featured\" — a second class defeats the literal match")
print(" 2. a nested <span> inside the cell — [^<]* stops at the '<'")
print(" 3. & is returned raw; the parser decodes it to '&'")
if __name__ == "__main__":
main()
metadata.yml (1202 bytes)
lesson_id: D079
day: 79
kind: python-program
languages: [python]
setup_commands:
- cd labs/sections/programming-with-python/day-079-web-scraping-responsibly
- python3 -m venv .venv
- .venv/bin/pip install -r requirements/requirements.txt
run_commands:
- cat examples/fixtures/robots.txt
- PYTHONPATH=examples .venv/bin/python3 examples/fixture_server.py
- PYTHONPATH=examples .venv/bin/python3 examples/regex_vs_parser.py
- PYTHONPATH=examples .venv/bin/python3 examples/demo.py catalogue.csv
- head -4 catalogue.csv
- SCRAPER_MODULE=starter .venv/bin/pytest tests -q
test_commands:
- bash tests/run_tests.sh
cleanup_commands:
- rm -f catalogue.csv
- rm -rf .venv
- 'git checkout -- starter/ # optional: reset your work'
requires_network: true
requires_api_key: false
estimated_minutes: 30
last_executed: '2026-07-19'
executed_on: 'macOS 26.5.1 (Apple Silicon), Python 3.14.0, beautifulsoup4 4.15.0, requests 2.34.2, pytest 9.1.1, bash 3.2.57 — bash tests/run_tests.sh -> 51 checks, 0 failure(s), exit 0; the pytest suite inside it reports 34 passed against examples/ and 33 failed, 1 passed against the unfinished starter. No test contacts any host other than 127.0.0.1.'
requirements/README.md (2993 bytes)
# Dependencies — Day 079 lab
Three packages, all free and open source, all pinned to the exact version this
lab was executed against.
| Pin | Why this lab needs it | Licence | Cost |
| --- | --- | --- | --- |
| `beautifulsoup4==4.15.0` | the HTML parser. Imported as `bs4`, not `beautifulsoup4` — the distribution name and the import name differ, which trips up nearly everyone once. | MIT | free |
| `requests==2.34.2` | the HTTP client from Day 78, used here with a `Session`, a timeout and an explicit `User-Agent` header. | Apache 2.0 | free |
| `pytest==9.1.1` | the test runner from Week 11. | MIT | free |
Everything else is standard library and needs no install:
| Module | Used for |
| --- | --- |
| `urllib.robotparser` | reading and applying `robots.txt` — the whole ethics half of this lab runs on a module that ships with Python |
| `urllib.parse` | `urljoin` for relative links, `urlparse` for the host guard |
| `http.server`, `socketserver`, `threading` | the local fixture server that stands in for a real site |
| `hashlib`, `json`, `pathlib` | the on-disk response cache |
| `csv` | the output, per Day 65 |
| `dataclasses`, `typing` | the `Item` type, per Day 69 |
Note what is **not** here. There is no `lxml`, no `selectolax`, no `scrapy`
and no `playwright`. The lesson's Alternatives section covers all four
honestly, and none of them is installed on this machine, so the lesson
describes them without quoting output it did not produce. `beautifulsoup4`
with the standard library's `html.parser` backend needs no compiler, works
everywhere Python works, and is the right default for a job this size.
## Installation
```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
```
`.venv/` is already ignored by the repository. The test runner finds
`.venv/bin/pytest` on its own; you never need to activate anything.
## The one thing that needs the network
Installing those three packages downloads them from the Python Package Index,
which needs an internet connection **once**. After that, **the tests need no
network at all** — that is the entire design of this lab. The fixture server
runs on 127.0.0.1, on a port the operating system assigns, serving HTML files
that ship in `examples/fixtures/`. If you are on a plane, and the packages are
already installed, everything here still passes.
## Verify your setup
```bash
.venv/bin/python3 -c "import bs4, requests; print(bs4.__version__, requests.__version__)"
```
That should print `4.15.0 2.34.2`. If `import bs4` fails with
`ModuleNotFoundError` after a successful `pip install beautifulsoup4`, you
have almost certainly installed into a different interpreter than the one you
are running — see `troubleshooting.md`.
## Windows
Run everything inside WSL, where the commands above work unchanged. Outside
WSL the paths become `.venv\Scripts\pip` and `.venv\Scripts\python`, and
`bash tests/run_tests.sh` needs Git Bash or WSL because the harness is a bash
script.
requirements/requirements.txt (54 bytes)
beautifulsoup4==4.15.0
requests==2.34.2
pytest==9.1.1
starter/catalogue_scraper.py (15334 bytes)
"""Day 079 starter — a polite, cached, parser-based scraper.
Six numbered exercises. Everything else in this file is finished and working,
including ``RobotsPolicy.load``, which is left complete on purpose so you have
a worked model of the style the rest of the file expects.
Work in order. After each exercise, run the suite against YOUR module:
SCRAPER_MODULE=starter .venv/bin/pytest tests -q
The number of failures should fall each time. When it reaches zero, run the
whole harness:
bash tests/run_tests.sh
The reference answer is in ``examples/catalogue_scraper.py``. Read it after
you have made each exercise pass, not before — the exercises are short, and
the value is entirely in the attempt.
Every boundary here is a parameter, not a global: the ``session``, the
``cache``, the ``sleeper``. That is Day 74's rule. Keep it that way, or the
tests will have to wait real seconds and you will stop running them.
"""
from __future__ import annotations
import csv
import hashlib
import json
import time
import urllib.robotparser
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterable, Sequence
from urllib.parse import urljoin, urlparse
import requests
from bs4 import BeautifulSoup
# Identify yourself honestly, and leave a way to be contacted. `example.com`
# is reserved by the IETF for documentation, so this address belongs to
# nobody; put a real one in a real scraper.
USER_AGENT = "HarbourCatalogueLab/1.0 (course exercise; contact: scraper-owner@example.com)"
DEFAULT_TIMEOUT = 10.0
FALLBACK_DELAY_SECONDS = 1.0
class ScrapeError(Exception):
"""Base class for everything this module refuses to do."""
class DisallowedByRobots(ScrapeError):
"""The path is disallowed by robots.txt for our User-Agent."""
class OffSite(ScrapeError):
"""The URL points somewhere other than the site we were asked to scrape."""
# ===========================================================================
# EXERCISE 1 — permission, before anything else
# ===========================================================================
#
# `RobotsPolicy.load` below is written for you. Implement the two questions
# the rest of the program asks of it.
#
# `allows`: return True when `self.parser.can_fetch(self.user_agent, url)` says
# so. One line. Note that can_fetch takes the User-Agent string, because
# robots.txt can carry different rules for different clients — the fixture
# robots.txt refuses "GreedyBot" everything, and a test checks that.
#
# `crawl_delay_seconds`: ask `self.parser.crawl_delay(self.user_agent)`. It
# returns None when the site declares no delay; in that case return
# `default`. Return a float either way.
#
# Tests: test_robots_allows_the_catalogue_and_refuses_the_private_path,
# test_a_named_user_agent_gets_its_own_rules,
# test_the_declared_crawl_delay_is_read_from_robots_txt,
# test_a_missing_crawl_delay_falls_back_to_a_polite_default
# ===========================================================================
@dataclass
class RobotsPolicy:
"""A parsed robots.txt, and the two questions worth asking it."""
parser: urllib.robotparser.RobotFileParser
user_agent: str
@classmethod
def load(
cls,
base_url: str,
*,
session: requests.Session,
user_agent: str = USER_AGENT,
timeout: float = DEFAULT_TIMEOUT,
) -> "RobotsPolicy":
"""WORKED EXAMPLE — read this, then write the two methods below."""
robots_url = urljoin(base_url, "/robots.txt")
parser = urllib.robotparser.RobotFileParser()
parser.set_url(robots_url)
response = session.get(
robots_url, headers={"User-Agent": user_agent}, timeout=timeout
)
if response.status_code == 200:
parser.parse(response.text.splitlines())
elif response.status_code in (401, 403):
parser.disallow_all = True
else:
parser.allow_all = True
return cls(parser=parser, user_agent=user_agent)
def allows(self, url: str) -> bool:
"""Is this exact URL allowed for our User-Agent?"""
raise NotImplementedError(
"Exercise 1a: return self.parser.can_fetch(self.user_agent, url)"
)
def crawl_delay_seconds(self, default: float = FALLBACK_DELAY_SECONDS) -> float:
"""Seconds to wait between requests, per robots.txt, else ``default``."""
raise NotImplementedError(
"Exercise 1b: ask self.parser.crawl_delay(self.user_agent); "
"return float(it) unless it is None, in which case return default"
)
# ===========================================================================
# EXERCISE 5 — the response cache
# ===========================================================================
#
# `get`: build the path with `self._path_for(url)`. If it exists, increment
# `self.hits` and return its text (encoding="utf-8"). Otherwise increment
# `self.misses` and return None. Returning None rather than raising is what
# lets `fetch_text` treat "not cached" as an ordinary case.
#
# `put`: write the text to `self._path_for(url)`, then add the filename-to-URL
# pair to `index.json` in the same directory so the cache is readable by a
# human. Load the existing index if the file is there.
#
# Tests: test_a_second_run_makes_no_page_requests,
# test_a_cache_hit_does_not_pay_the_crawl_delay,
# test_the_cache_directory_is_inspectable
# ===========================================================================
@dataclass
class ResponseCache:
"""Response bodies stored on disk, keyed by URL."""
directory: Path
hits: int = 0
misses: int = 0
def __post_init__(self) -> None:
self.directory = Path(self.directory)
self.directory.mkdir(parents=True, exist_ok=True)
def _path_for(self, url: str) -> Path:
"""WORKED — a stable filename per URL, safe on every filesystem."""
digest = hashlib.sha256(url.encode("utf-8")).hexdigest()[:32]
return self.directory / f"{digest}.html"
def get(self, url: str) -> str | None:
raise NotImplementedError(
"Exercise 5a: return the cached text and count a hit, or None and "
"count a miss"
)
def put(self, url: str, text: str) -> None:
raise NotImplementedError(
"Exercise 5b: write the body to self._path_for(url), then record "
"{filename: url} in index.json alongside it"
)
# ===========================================================================
# fetch_text — WORKED. Read the ORDER of the four steps; it is the lesson.
# ===========================================================================
def fetch_text(
url: str,
*,
session: requests.Session,
robots: RobotsPolicy,
cache: ResponseCache | None = None,
sleeper: Callable[[float], None] = time.sleep,
delay_seconds: float | None = None,
user_agent: str = USER_AGENT,
timeout: float = DEFAULT_TIMEOUT,
) -> str:
"""Return the body of ``url``, from cache if possible, politely if not."""
# 1. Permission, before a socket is opened.
if not robots.allows(url):
raise DisallowedByRobots(url)
# 2. Cache, so a page is fetched once in its life.
if cache is not None:
cached = cache.get(url)
if cached is not None:
return cached
# 3. The crawl delay, paid only by a real fetch.
wait = robots.crawl_delay_seconds() if delay_seconds is None else delay_seconds
if wait > 0:
sleeper(wait)
# 4. The request itself, with a timeout (Day 78) and an honest User-Agent.
response = session.get(url, headers={"User-Agent": user_agent}, timeout=timeout)
response.raise_for_status()
text = response.text
if cache is not None:
cache.put(url, text)
return text
@dataclass(frozen=True)
class Item:
"""One catalogue row. ``price`` is ``None`` when the page omits the cell."""
sku: str
name: str
price: float | None
stock: int | None
source_path: str
# ===========================================================================
# EXERCISE 3 — survive the missing element
# ===========================================================================
#
# This is the four-line function that decides whether your scraper crashes at
# 3 a.m. on row 4,812. Write it before Exercise 2 uses it.
#
# cell = row.select_one(selector) # returns None, never raises
# if cell is None: return None # the caller decides what that means
# for extra in cell.select(drop): extra.extract() # only when drop is given
# return cell.get_text(" ", strip=True)
#
# Two things `get_text(" ", strip=True)` does for you: it strips the
# whitespace HTML authors leave around text, and it joins nested text with a
# space instead of gluing words together. `drop` exists because get_text
# sweeps up EVERY descendant, including a decorative <span class="badge">.
#
# Tests: test_the_item_with_no_price_cell_parses_instead_of_crashing,
# test_a_row_with_no_cells_at_all_still_parses,
# test_whitespace_wrapped_text_is_stripped,
# test_a_decorative_nested_tag_is_kept_out_of_the_name
# ===========================================================================
def _cell_text(row, selector: str, *, drop: str | None = None) -> str | None:
"""Text of the first cell matching ``selector``, or ``None`` if absent."""
raise NotImplementedError(
"Exercise 3: select_one, check for None, optionally extract() the "
"elements matching `drop`, then get_text(' ', strip=True)"
)
# ===========================================================================
# EXERCISE 2 — extract the table with CSS selectors
# ===========================================================================
#
# soup = BeautifulSoup(html, "html.parser")
# for row in soup.select("table.catalogue tr.item"):
# sku = row.get("data-sku", "")
# name = _cell_text(row, "td.name", drop="span.badge") or ""
# price_text = _cell_text(row, "td.price")
# stock_text = _cell_text(row, "td.stock")
# ... build an Item, converting price to float and stock to int, but
# leaving each as None when its text is missing or empty ...
#
# Why `tr.item` and `td.name` rather than a regular expression: a CSS class
# selector matches a class that is PRESENT in the attribute, so it still finds
# `class="name featured"`. The obvious regex for `class="name"` does not, and
# it will not tell you that it missed anything.
#
# Tests: test_the_first_page_yields_four_items_in_document_order,
# test_a_second_class_on_the_cell_does_not_hide_it,
# test_an_html_entity_is_decoded_not_returned_raw,
# test_prices_and_stock_are_numbers_not_strings
# ===========================================================================
def parse_items(html: str, *, source_path: str = "") -> list[Item]:
"""Extract every catalogue row from one page."""
raise NotImplementedError(
"Exercise 2: BeautifulSoup(html, 'html.parser').select("
"'table.catalogue tr.item') and build one Item per row"
)
# ===========================================================================
# EXERCISE 4 — pagination, to the end and no further
# ===========================================================================
#
# `next_page_url`: find `nav.pager a.next` with select_one. If there is no
# such link — the last page has a <span class="next disabled"> instead —
# return None. Otherwise return `urljoin(current_url, link.get("href"))`, so
# the relative "page-2.html" becomes an absolute URL.
#
# `scrape_catalogue`: loop from `start_url`. On each turn, refuse a URL whose
# netloc differs from the start URL's (raise OffSite), stop if you have seen
# this URL before or have hit `max_pages`, fetch it with `fetch_text`,
# extend the item list with `parse_items(html, source_path=urlparse(url).path)`,
# and move on to `next_page_url(html, url)`.
#
# Do NOT stop after a fixed number of pages or when the item count reaches
# twelve. Pages get added. Stop when the site says there is no next page.
#
# Tests: test_next_page_url_is_resolved_against_the_current_url,
# test_the_last_page_reports_no_next_page,
# test_pagination_visits_every_page_exactly_once,
# test_the_crawl_finds_every_item_across_all_pages,
# test_an_off_site_link_is_refused_rather_than_followed
# ===========================================================================
def next_page_url(html: str, current_url: str) -> str | None:
"""Absolute URL of the next page, or ``None`` when this was the last one."""
raise NotImplementedError(
"Exercise 4a: select_one('nav.pager a.next'); None when absent; "
"otherwise urljoin(current_url, href)"
)
def scrape_catalogue(
start_url: str,
*,
session: requests.Session,
robots: RobotsPolicy,
cache: ResponseCache | None = None,
sleeper: Callable[[float], None] = time.sleep,
delay_seconds: float | None = None,
max_pages: int = 50,
) -> list[Item]:
"""Follow the pagination from ``start_url`` and return every item found."""
raise NotImplementedError(
"Exercise 4b: the while loop — guard the host, guard against repeats "
"and max_pages, fetch, parse, then follow next_page_url"
)
def follow_links_politely(
html: str,
base_url: str,
*,
robots: RobotsPolicy,
) -> tuple[list[str], list[str]]:
"""WORKED — split every link on a page into (allowed, refused)."""
soup = BeautifulSoup(html, "html.parser")
allowed: list[str] = []
refused: list[str] = []
for anchor in soup.select("a[href]"):
target = urljoin(base_url, anchor["href"])
(allowed if robots.allows(target) else refused).append(target)
return allowed, refused
# ===========================================================================
# EXERCISE 6 — write the CSV (Day 65)
# ===========================================================================
#
# open the path with encoding="utf-8" and newline="" — the csv module
# requires newline="", and forgetting it gives you blank lines on Windows
# write CSV_COLUMNS as the header row
# one row per item: sku, name, price formatted to two decimals, stock,
# source_path — and an EMPTY STRING, not the word "None", where a value is
# missing. A CSV containing the string None is a CSV that will be loaded as
# text by whatever reads it next.
#
# Tests: test_the_csv_has_a_header_and_one_row_per_item,
# test_the_missing_price_becomes_an_empty_field_not_the_word_none,
# test_the_ampersand_survives_the_round_trip
# ===========================================================================
CSV_COLUMNS: Sequence[str] = ("sku", "name", "price", "stock", "source_path")
def write_csv(items: Iterable[Item], path: Path) -> Path:
"""Write items to ``path`` as CSV and return the path."""
raise NotImplementedError(
"Exercise 6: csv.writer into an open(..., newline='', encoding='utf-8'), "
"header first, empty string for a missing price or stock"
)
tests/run_tests.sh (19014 bytes)
#!/usr/bin/env bash
# Tests for the Day 079 lab. Run from the lab directory:
# bash tests/run_tests.sh
#
# NOTHING HERE TOUCHES THE INTERNET. Section 1 proves that rather than
# asserting it: the only absolute URLs in the lab's source point at 127.0.0.1
# or at 198.51.100.0/24, the range the IETF reserves for documentation, and
# the one reference to that range exists precisely so a test can watch the
# scraper REFUSE to connect to it. Everything else is served by a local
# fixture server on a port the operating system picks at run time.
#
# The check worth reading is in section 4. The fixture site links a page that
# robots.txt disallows, and the server will serve it to anyone who asks. The
# harness asserts, against the SERVER's own access log, that the path was
# never requested. A scraper that only describes good behaviour cannot pass
# that check — which is the difference between ethics implemented and ethics
# described.
#
# Non-interactive, deterministic, exits 0 only if every check passes.
set -u
export PYTHONDONTWRITEBYTECODE=1
lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
failures=0
checks=0
check() {
local label="$1" ok="$2"
checks=$((checks + 1))
if [ "${ok}" = "yes" ]; then
echo " ok: ${label}"
else
echo " FAIL: ${label}"
failures=$((failures + 1))
fi
}
# Resolve tools: an explicit override, then this lab's .venv, then PATH.
# Fails loudly with instructions rather than silently skipping.
resolve_tool() {
local tool="$1" override="$2"
if [ -n "${override}" ] && [ -x "${override}" ]; then echo "${override}"; return 0; fi
if [ -x "${lab_dir}/.venv/bin/${tool}" ]; then echo "${lab_dir}/.venv/bin/${tool}"; return 0; fi
if command -v "${tool}" >/dev/null 2>&1; then command -v "${tool}"; return 0; fi
return 1
}
pytest_bin="$(resolve_tool pytest "${PYTEST:-}")" || {
echo "FAIL: pytest not found." >&2
echo " Install this lab's dependencies with:" >&2
echo " python3 -m venv .venv" >&2
echo " .venv/bin/pip install -r requirements/requirements.txt" >&2
echo " Or point this suite at an existing pytest: PYTEST=/path/to/pytest bash tests/run_tests.sh" >&2
exit 1
}
python_bin="$(resolve_tool python3 "${PYTHON:-}")" || {
echo "FAIL: python3 not found on PATH." >&2
exit 1
}
if [ -x "${lab_dir}/.venv/bin/python3" ]; then
python_bin="${lab_dir}/.venv/bin/python3"
fi
echo "Day 079 — Scrape a Site You Are Allowed To"
echo
# --------------------------------------------------------------------------
echo "1. Offline by construction"
# --------------------------------------------------------------------------
# Any absolute http(s) URL beginning with a letter would be a hostname, which
# would mean a DNS lookup. There must be none in the lab's own source.
if grep -rEn 'https?://[A-Za-z]' \
"${lab_dir}/examples" "${lab_dir}/starter" "${lab_dir}/tests" >/dev/null 2>&1; then
check "no hostname-bearing URL anywhere in examples/, starter/ or tests/" "no"
grep -rEn 'https?://[A-Za-z]' "${lab_dir}/examples" "${lab_dir}/starter" "${lab_dir}/tests"
else
check "no hostname-bearing URL anywhere in examples/, starter/ or tests/" "yes"
fi
# The one absolute URL with a foreign host is the documentation-reserved
# address in the detour fixture, and it exists to be refused.
detour_hits="$(grep -Ec '198\.51\.100\.' "${lab_dir}/examples/fixtures/detour/page-1.html" || true)"
if [ "${detour_hits}" = "1" ]; then
check "the only off-site link is a documentation-reserved address (198.51.100.0/24)" "yes"
else
check "the only off-site link is a documentation-reserved address (got ${detour_hits} occurrences)" "no"
fi
# The server binds port 0, not a fixed port, so it never collides.
if grep -q '("127.0.0.1", 0)' "${lab_dir}/examples/fixture_server.py"; then
check "the fixture server binds port 0 and reads back the assigned port" "yes"
else
check "the fixture server binds port 0 and reads back the assigned port" "no"
fi
# Two servers started in a row must land on different ports — that is what
# "ephemeral" means, and it is why the lab cannot clash with your own work.
ports="$(cd "${lab_dir}" && PYTHONPATH=examples "${python_bin}" - <<'PY'
from fixture_server import serve_fixtures
with serve_fixtures() as a, serve_fixtures() as b:
print(a.port, b.port, a.port != b.port)
PY
)"
case "${ports}" in
*True) check "two fixture servers get two different ephemeral ports ( ${ports% True} )" "yes" ;;
*) check "two fixture servers get two different ephemeral ports (got '${ports}')" "no" ;;
esac
# --------------------------------------------------------------------------
echo
echo "2. The tools"
# --------------------------------------------------------------------------
version_line="$("${pytest_bin}" --version 2>&1 | head -1)"
case "${version_line}" in
pytest*) check "pytest --version reports a pytest ( ${version_line} )" "yes" ;;
*) check "pytest --version reports a pytest ( ${version_line} )" "no" ;;
esac
bs4_version="$("${python_bin}" -c 'import bs4; print(bs4.__version__)' 2>&1)"
case "${bs4_version}" in
4.*) check "beautifulsoup4 imports as bs4 ( ${bs4_version} )" "yes" ;;
*) check "beautifulsoup4 imports as bs4 (got: ${bs4_version})" "no" ;;
esac
requests_version="$("${python_bin}" -c 'import requests; print(requests.__version__)' 2>&1)"
case "${requests_version}" in
2.*) check "requests is importable ( ${requests_version} )" "yes" ;;
*) check "requests is importable (got: ${requests_version})" "no" ;;
esac
# --------------------------------------------------------------------------
echo
echo "3. The reference suite"
# --------------------------------------------------------------------------
suite_out="$(cd "${lab_dir}" && "${pytest_bin}" tests -q 2>&1)"
suite_exit=$?
if [ "${suite_exit}" -eq 0 ]; then
check "pytest tests exits 0 against examples/" "yes"
else
check "pytest tests exits 0 against examples/ (got ${suite_exit})" "no"
echo "${suite_out}" | tail -30
fi
case "${suite_out}" in
*"34 passed"*) check "the reference suite reports 34 passed" "yes" ;;
*) check "the reference suite reports 34 passed (got: $(printf '%s' "${suite_out}" | tail -1))" "no" ;;
esac
# --------------------------------------------------------------------------
echo
echo "4. Ethics implemented, not described"
# --------------------------------------------------------------------------
# The private page IS linked from page 1 and the server WILL serve it. The
# only reason it is never fetched is that the scraper asks robots.txt first.
if grep -q '/private/internal-notes.html' "${lab_dir}/examples/fixtures/catalogue/page-1.html"; then
check "the fixture really does link the disallowed page from page 1" "yes"
else
check "the fixture really does link the disallowed page from page 1" "no"
fi
if grep -q 'Disallow: /private/' "${lab_dir}/examples/fixtures/robots.txt"; then
check "robots.txt really does disallow /private/ for every User-Agent" "yes"
else
check "robots.txt really does disallow /private/ for every User-Agent" "no"
fi
# Independent evidence: ask the server, not the scraper.
private_report="$(cd "${lab_dir}" && PYTHONPATH=examples "${python_bin}" - <<'PY'
import tempfile
from pathlib import Path
import requests
from catalogue_scraper import ResponseCache, RobotsPolicy, scrape_catalogue
from fixture_server import serve_fixtures
with serve_fixtures() as site, requests.Session() as session:
robots = RobotsPolicy.load(site.base_url, session=session)
cache = ResponseCache(Path(tempfile.mkdtemp(prefix="d079-")))
items = scrape_catalogue(
site.url_for("/catalogue/page-1.html"),
session=session,
robots=robots,
cache=cache,
sleeper=lambda seconds: None,
)
print("private_requests", site.total_count("/private/internal-notes.html"))
print("items", len(items))
print("pages", len([p for p in site.paths() if p.startswith("/catalogue/")]))
print("agents", len(site.all_user_agents()))
PY
)"
case "${private_report}" in
*"private_requests 0"*) check "the disallowed path was requested ZERO times, per the server's own log" "yes" ;;
*) check "the disallowed path was requested ZERO times (report: ${private_report})" "no" ;;
esac
case "${private_report}" in
*"pages 3"*) check "pagination followed all 3 pages and stopped" "yes" ;;
*) check "pagination followed all 3 pages and stopped" "no" ;;
esac
case "${private_report}" in
*"items 12"*) check "the crawl found all 12 items" "yes" ;;
*) check "the crawl found all 12 items" "no" ;;
esac
case "${private_report}" in
*"agents 1"*) check "exactly one User-Agent string was sent — the client identified itself" "yes" ;;
*) check "exactly one User-Agent string was sent" "no" ;;
esac
# The named-agent rules in robots.txt are honoured too.
greedy="$(cd "${lab_dir}" && PYTHONPATH=examples "${python_bin}" - <<'PY'
import requests
from catalogue_scraper import RobotsPolicy
from fixture_server import serve_fixtures
with serve_fixtures() as site, requests.Session() as session:
polite = RobotsPolicy.load(site.base_url, session=session)
greedy = RobotsPolicy.load(site.base_url, session=session, user_agent="GreedyBot")
target = site.url_for("/catalogue/page-1.html")
print("polite", polite.allows(target), "greedy", greedy.allows(target),
"delay", polite.crawl_delay_seconds())
PY
)"
case "${greedy}" in
"polite True greedy False delay 1.0") check "robots.txt rules are per-User-Agent ( ${greedy} )" "yes" ;;
*) check "robots.txt rules are per-User-Agent (got: ${greedy})" "no" ;;
esac
# --------------------------------------------------------------------------
echo
echo "5. The messy realities"
# --------------------------------------------------------------------------
messy="$(cd "${lab_dir}" && PYTHONPATH=examples "${python_bin}" - <<'PY'
from pathlib import Path
from catalogue_scraper import parse_items
base = Path("examples/fixtures/catalogue")
one = parse_items(base.joinpath("page-1.html").read_text(encoding="utf-8"))
two = parse_items(base.joinpath("page-2.html").read_text(encoding="utf-8"))
three = parse_items(base.joinpath("page-3.html").read_text(encoding="utf-8"))
by_sku = {item.sku: item for item in one + two + three}
print("missing_price", by_sku["NAV-003"].price, "rows_on_page_2", len(two))
print("entity", repr(by_sku["STA-001"].name))
print("nested", repr(by_sku["STA-002"].name))
print("second_class", repr(by_sku["NAV-002"].name))
print("whitespace", repr(by_sku["TOO-003"].name))
PY
)"
case "${messy}" in
*"missing_price None rows_on_page_2 4"*)
check "the item with no price cell yields price=None and does not shorten the page" "yes" ;;
*) check "the item with no price cell yields price=None (got: ${messy})" "no" ;;
esac
case "${messy}" in
*"entity 'Ink & Quill Set'"*) check "the HTML entity & is decoded to '&'" "yes" ;;
*) check "the HTML entity & is decoded to '&'" "no" ;;
esac
case "${messy}" in
*"nested 'Vellum Notebook'"*) check "the decorative nested tag is kept out of the name" "yes" ;;
*) check "the decorative nested tag is kept out of the name" "no" ;;
esac
case "${messy}" in
*"second_class 'Mariner Astrolabe'"*) check "a cell with two classes is still found by td.name" "yes" ;;
*) check "a cell with two classes is still found by td.name" "no" ;;
esac
case "${messy}" in
*"whitespace 'Linen Thread Spool'"*) check "whitespace-wrapped text is stripped" "yes" ;;
*) check "whitespace-wrapped text is stripped" "no" ;;
esac
# --------------------------------------------------------------------------
echo
echo "6. The regular expression fails where the parser does not"
# --------------------------------------------------------------------------
regex_out="$(cd "${lab_dir}" && PYTHONPATH=examples "${python_bin}" examples/regex_vs_parser.py 2>&1)"
regex_exit=$?
if [ "${regex_exit}" -eq 0 ]; then
check "examples/regex_vs_parser.py exits 0" "yes"
else
check "examples/regex_vs_parser.py exits 0 (got ${regex_exit})" "no"
fi
case "${regex_out}" in
*"regular expression found: 10 names"*) check "the naive regex finds only 10 of the 12 names" "yes" ;;
*) check "the naive regex finds only 10 of the 12 names" "no" ;;
esac
case "${regex_out}" in
*"BeautifulSoup found: 12 names"*) check "the parser finds all 12" "yes" ;;
*) check "the parser finds all 12" "no" ;;
esac
case "${regex_out}" in
*"'Ink & Quill Set'"*) check "the regex hands back an undecoded HTML entity" "yes" ;;
*) check "the regex hands back an undecoded HTML entity" "no" ;;
esac
# --------------------------------------------------------------------------
echo
echo "7. The cache means a re-run costs the site nothing"
# --------------------------------------------------------------------------
cache_report="$(cd "${lab_dir}" && PYTHONPATH=examples "${python_bin}" - <<'PY'
import shutil, tempfile
from pathlib import Path
import requests
from catalogue_scraper import ResponseCache, RobotsPolicy, scrape_catalogue
from fixture_server import serve_fixtures
cache_dir = Path(tempfile.mkdtemp(prefix="d079-cache-"))
with serve_fixtures() as site, requests.Session() as session:
robots = RobotsPolicy.load(site.base_url, session=session)
start = site.url_for("/catalogue/page-1.html")
first = scrape_catalogue(start, session=session, robots=robots,
cache=ResponseCache(cache_dir), sleeper=lambda s: None)
site.reset()
waits = []
warm = ResponseCache(cache_dir)
second = scrape_catalogue(start, session=session, robots=robots,
cache=warm, sleeper=waits.append)
pages = [p for p in site.paths() if p != "/robots.txt"]
print("second_run_page_requests", len(pages))
print("hits", warm.hits, "misses", warm.misses)
print("identical", first == second)
print("sleeps_on_cache_hits", len(waits))
print("cache_files", len(list(cache_dir.glob("*.html"))))
shutil.rmtree(cache_dir, ignore_errors=True)
PY
)"
case "${cache_report}" in
*"second_run_page_requests 0"*) check "a second run makes ZERO page requests" "yes" ;;
*) check "a second run makes ZERO page requests (report: ${cache_report})" "no" ;;
esac
case "${cache_report}" in
*"hits 3 misses 0"*) check "all three pages came from the cache" "yes" ;;
*) check "all three pages came from the cache" "no" ;;
esac
case "${cache_report}" in
*"identical True"*) check "the cached run produces byte-identical items" "yes" ;;
*) check "the cached run produces byte-identical items" "no" ;;
esac
case "${cache_report}" in
*"sleeps_on_cache_hits 0"*) check "a cache hit does not pay the crawl delay" "yes" ;;
*) check "a cache hit does not pay the crawl delay" "no" ;;
esac
case "${cache_report}" in
*"cache_files 3"*) check "the cache holds one file per page fetched" "yes" ;;
*) check "the cache holds one file per page fetched" "no" ;;
esac
# --------------------------------------------------------------------------
echo
echo "8. End to end: the demo and its CSV"
# --------------------------------------------------------------------------
work="$(mktemp -d "${TMPDIR:-/tmp}/d079-demo.XXXXXX")"
demo_out="$(cd "${lab_dir}" && PYTHONPATH=examples "${python_bin}" examples/demo.py "${work}/catalogue.csv" 2>&1)"
demo_exit=$?
if [ "${demo_exit}" -eq 0 ]; then
check "examples/demo.py exits 0" "yes"
else
check "examples/demo.py exits 0 (got ${demo_exit})" "no"
echo "${demo_out}" | tail -20
fi
case "${demo_out}" in
*"requests for /private/internal-notes.html: 0"*)
check "the demo's closing server log shows zero requests for the disallowed path" "yes" ;;
*) check "the demo's closing server log shows zero requests for the disallowed path" "no" ;;
esac
case "${demo_out}" in
*"page requests this run: 0"*) check "the demo's second run makes no page requests" "yes" ;;
*) check "the demo's second run makes no page requests" "no" ;;
esac
if [ -f "${work}/catalogue.csv" ]; then
csv_lines="$(wc -l < "${work}/catalogue.csv" | tr -d ' ')"
if [ "${csv_lines}" = "13" ]; then
check "the CSV has 13 lines: a header and 12 items" "yes"
else
check "the CSV has 13 lines (got ${csv_lines})" "no"
fi
# The csv module's default dialect ends lines with CRLF, as RFC 4180 asks.
# That is correct, and it is also why this comparison strips the carriage
# return rather than pretending the byte is not there.
header="$(head -1 "${work}/catalogue.csv" | tr -d '\r')"
if [ "${header}" = "sku,name,price,stock,source_path" ]; then
check "the CSV header names the five columns" "yes"
else
check "the CSV header names the five columns (got '${header}')" "no"
fi
if head -1 "${work}/catalogue.csv" | grep -q $'\r'; then
check "the CSV uses RFC 4180 CRLF line endings, as the csv module defaults to" "yes"
else
check "the CSV uses RFC 4180 CRLF line endings" "no"
fi
if grep -q '^NAV-003,Brass Compass,,4,' "${work}/catalogue.csv"; then
check "the price-less item writes an EMPTY field, not the word None" "yes"
else
check "the price-less item writes an EMPTY field, not the word None" "no"
fi
if grep -q 'Ink & Quill Set' "${work}/catalogue.csv"; then
check "the ampersand survives HTML, parsing and CSV" "yes"
else
check "the ampersand survives HTML, parsing and CSV" "no"
fi
if grep -q 'None' "${work}/catalogue.csv"; then
check "the CSV contains no literal 'None'" "no"
else
check "the CSV contains no literal 'None'" "yes"
fi
else
check "examples/demo.py wrote the CSV" "no"
fi
rm -rf "${work}"
# --------------------------------------------------------------------------
echo
echo "9. The starter's exercises are load-bearing"
# --------------------------------------------------------------------------
starter_out="$(cd "${lab_dir}" && SCRAPER_MODULE=starter "${pytest_bin}" tests -q 2>&1)"
starter_exit=$?
if [ "${starter_exit}" -ne 0 ]; then
check "the same suite FAILS against the unfinished starter (exit ${starter_exit})" "yes"
else
check "the same suite FAILS against the unfinished starter — it did not, so the exercises are vacuous" "no"
fi
case "${starter_out}" in
*"NotImplementedError"*) check "the starter fails on its own NotImplementedError, not on an import error" "yes" ;;
*) check "the starter fails on its own NotImplementedError, not on an import error" "no" ;;
esac
case "${starter_out}" in
*"1 passed"*) check "exactly one test passes on the starter — the worked RobotsPolicy.load" "yes" ;;
*) check "exactly one test passes on the starter ( $(printf '%s' "${starter_out}" | tail -1) )" "no" ;;
esac
for exercise in "Exercise 1a" "Exercise 1b" "Exercise 2" "Exercise 3" "Exercise 4a" "Exercise 4b" "Exercise 5a" "Exercise 5b" "Exercise 6"; do
if grep -q "${exercise}" "${lab_dir}/starter/catalogue_scraper.py"; then
check "the starter names ${exercise}" "yes"
else
check "the starter names ${exercise}" "no"
fi
done
echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]
tests/test_scraper.py (17254 bytes)
"""The reference suite for Day 079.
It runs against ``examples/catalogue_scraper.py`` by default and against
``starter/catalogue_scraper.py`` when ``SCRAPER_MODULE=starter`` is set. The
starter run is expected to FAIL until the six exercises are finished — that is
how ``tests/run_tests.sh`` proves the exercises are load-bearing rather than
decorative.
Every test in this file is offline. The only socket opened points at
127.0.0.1, at a port the operating system chose, serving files that ship with
this lab. Nothing resolves a hostname and nothing leaves the machine.
The check that matters most is ``test_the_disallowed_path_is_never_requested``.
It does not ask the scraper what it did; it asks the *server* what it was
asked for. A scraper that describes good behaviour in a docstring cannot pass
it.
"""
from __future__ import annotations
import importlib
import os
import re
import sys
from pathlib import Path
import pytest
import requests
LAB_DIR = Path(__file__).resolve().parent.parent
WHICH = os.environ.get("SCRAPER_MODULE", "examples")
sys.path.insert(0, str(LAB_DIR / "examples"))
sys.path.insert(0, str(LAB_DIR / WHICH))
scraper = importlib.import_module("catalogue_scraper")
from fixture_server import serve_fixtures # noqa: E402
EXPECTED_ITEM_COUNT = 12
EXPECTED_PAGES = [
"/catalogue/page-1.html",
"/catalogue/page-2.html",
"/catalogue/page-3.html",
]
PRIVATE_PATH = "/private/internal-notes.html"
class RecordingSleeper:
"""A stand-in for ``time.sleep`` that records instead of waiting.
Day 74's boundary rule, applied to the clock: the scraper takes a
``sleeper`` parameter, so the suite can assert that it asked to wait one
second between fetches without the suite taking three seconds to run.
"""
def __init__(self) -> None:
self.waits: list[float] = []
def __call__(self, seconds: float) -> None:
self.waits.append(seconds)
@pytest.fixture
def site():
with serve_fixtures() as server:
yield server
@pytest.fixture
def session():
with requests.Session() as s:
yield s
@pytest.fixture
def robots(site, session):
return scraper.RobotsPolicy.load(site.base_url, session=session)
# ---------------------------------------------------------------------------
# Exercise 1 — robots.txt is consulted before anything is fetched
# ---------------------------------------------------------------------------
def test_robots_txt_is_fetched_with_our_own_user_agent(site, session):
scraper.RobotsPolicy.load(site.base_url, session=session)
assert site.paths() == ["/robots.txt"]
assert site.user_agents() == {scraper.USER_AGENT}
def test_robots_allows_the_catalogue_and_refuses_the_private_path(site, robots):
assert robots.allows(site.url_for("/catalogue/page-1.html")) is True
assert robots.allows(site.url_for(PRIVATE_PATH)) is False
def test_a_named_user_agent_gets_its_own_rules(site, session):
greedy = scraper.RobotsPolicy.load(
site.base_url, session=session, user_agent="GreedyBot"
)
assert greedy.allows(site.url_for("/catalogue/page-1.html")) is False
def test_the_declared_crawl_delay_is_read_from_robots_txt(robots):
assert robots.crawl_delay_seconds() == 1.0
def test_a_missing_crawl_delay_falls_back_to_a_polite_default(site, session, tmp_path):
bare = tmp_path / "site"
(bare / "catalogue").mkdir(parents=True)
(bare / "robots.txt").write_text("User-agent: *\nDisallow: /nowhere/\n")
with serve_fixtures(bare) as plain_site:
policy = scraper.RobotsPolicy.load(plain_site.base_url, session=session)
assert policy.crawl_delay_seconds() == pytest.approx(1.0)
assert policy.crawl_delay_seconds(default=2.5) == pytest.approx(2.5)
def test_fetching_a_disallowed_url_raises_before_any_request(site, session, robots):
site.reset()
with pytest.raises(scraper.DisallowedByRobots):
scraper.fetch_text(
site.url_for(PRIVATE_PATH),
session=session,
robots=robots,
sleeper=RecordingSleeper(),
)
assert site.paths() == []
def test_the_disallowed_path_is_never_requested(site, session, robots, tmp_path):
"""The check that proves the ethics were implemented, not described.
The private page is linked from page 1, the server will happily serve it,
and the crawl walks right past it. The assertion reads the SERVER's log.
"""
site.reset()
cache = scraper.ResponseCache(tmp_path / "cache")
scraper.scrape_catalogue(
site.url_for("/catalogue/page-1.html"),
session=session,
robots=robots,
cache=cache,
sleeper=RecordingSleeper(),
)
first_page = (LAB_DIR / "examples/fixtures/catalogue/page-1.html").read_text()
assert PRIVATE_PATH in first_page, "the fixture must actually link the private page"
assert site.count(PRIVATE_PATH) == 0
assert site.total_count(PRIVATE_PATH) == 0
def test_links_are_sorted_into_allowed_and_refused(site, session, robots):
start = site.url_for("/catalogue/page-1.html")
html = scraper.fetch_text(
start, session=session, robots=robots, sleeper=RecordingSleeper()
)
allowed, refused = scraper.follow_links_politely(html, start, robots=robots)
assert refused == [site.url_for(PRIVATE_PATH)]
assert site.url_for("/catalogue/page-2.html") in allowed
def test_the_site_publishes_a_sitemap_that_beats_guessing(site, session, robots):
"""The alternatives-first rule, in miniature.
robots.txt names a sitemap. Reading it gives you every page directly,
with no pagination logic to get wrong and no pages missed when the site
adds a fourth one. Look for this before you write a crawl loop.
"""
sitemap_url = site.url_for("/sitemap.txt")
body = scraper.fetch_text(
sitemap_url, session=session, robots=robots, sleeper=RecordingSleeper()
)
listed = [line.strip() for line in body.splitlines() if line.strip()]
assert listed == EXPECTED_PAGES
# ---------------------------------------------------------------------------
# Exercise 2 — CSS selectors extract the table
# ---------------------------------------------------------------------------
def page_html(name: str) -> str:
return (LAB_DIR / "examples/fixtures/catalogue" / name).read_text(encoding="utf-8")
def test_the_first_page_yields_four_items_in_document_order():
items = scraper.parse_items(page_html("page-1.html"), source_path="/p1")
assert [item.sku for item in items] == ["NAV-001", "NAV-002", "STA-001", "STA-002"]
def test_a_second_class_on_the_cell_does_not_hide_it():
items = scraper.parse_items(page_html("page-1.html"))
astrolabe = next(item for item in items if item.sku == "NAV-002")
assert astrolabe.name == "Mariner Astrolabe"
assert astrolabe.price == pytest.approx(128.50)
def test_an_html_entity_is_decoded_not_returned_raw():
items = scraper.parse_items(page_html("page-1.html"))
quills = next(item for item in items if item.sku == "STA-001")
assert quills.name == "Ink & Quill Set"
assert "&" not in quills.name
def test_a_decorative_nested_tag_is_kept_out_of_the_name():
items = scraper.parse_items(page_html("page-1.html"))
notebook = next(item for item in items if item.sku == "STA-002")
assert notebook.name == "Vellum Notebook"
def test_whitespace_wrapped_text_is_stripped():
items = scraper.parse_items(page_html("page-3.html"))
thread = next(item for item in items if item.sku == "TOO-003")
assert thread.name == "Linen Thread Spool"
def test_prices_and_stock_are_numbers_not_strings():
items = scraper.parse_items(page_html("page-1.html"))
sextant = items[0]
assert isinstance(sextant.price, float)
assert isinstance(sextant.stock, int)
assert sextant.price == pytest.approx(42.00)
assert sextant.stock == 7
# ---------------------------------------------------------------------------
# Exercise 3 — survive the missing element
# ---------------------------------------------------------------------------
def test_the_item_with_no_price_cell_parses_instead_of_crashing():
items = scraper.parse_items(page_html("page-2.html"), source_path="/p2")
compass = next(item for item in items if item.sku == "NAV-003")
assert compass.name == "Brass Compass"
assert compass.price is None
assert compass.stock == 4
def test_the_missing_price_does_not_shorten_the_page():
items = scraper.parse_items(page_html("page-2.html"))
assert len(items) == 4
def test_a_row_with_no_cells_at_all_still_parses():
html = '<table class="catalogue"><tr class="item" data-sku="X-1"></tr></table>'
items = scraper.parse_items(html)
assert len(items) == 1
assert items[0].sku == "X-1"
assert items[0].name == ""
assert items[0].price is None
# ---------------------------------------------------------------------------
# Exercise 4 — pagination to the end
# ---------------------------------------------------------------------------
def test_next_page_url_is_resolved_against_the_current_url(site):
current = site.url_for("/catalogue/page-1.html")
assert scraper.next_page_url(page_html("page-1.html"), current) == site.url_for(
"/catalogue/page-2.html"
)
def test_the_last_page_reports_no_next_page(site):
current = site.url_for("/catalogue/page-3.html")
assert scraper.next_page_url(page_html("page-3.html"), current) is None
def test_pagination_visits_every_page_exactly_once(site, session, robots, tmp_path):
site.reset()
scraper.scrape_catalogue(
site.url_for("/catalogue/page-1.html"),
session=session,
robots=robots,
cache=scraper.ResponseCache(tmp_path / "cache"),
sleeper=RecordingSleeper(),
)
assert site.paths() == EXPECTED_PAGES
def test_the_crawl_finds_every_item_across_all_pages(site, session, robots, tmp_path):
items = scraper.scrape_catalogue(
site.url_for("/catalogue/page-1.html"),
session=session,
robots=robots,
cache=scraper.ResponseCache(tmp_path / "cache"),
sleeper=RecordingSleeper(),
)
assert len(items) == EXPECTED_ITEM_COUNT
assert len({item.sku for item in items}) == EXPECTED_ITEM_COUNT
assert sum(1 for item in items if item.price is None) == 1
def test_the_crawl_waits_the_declared_delay_before_each_real_fetch(
site, session, robots, tmp_path
):
sleeper = RecordingSleeper()
scraper.scrape_catalogue(
site.url_for("/catalogue/page-1.html"),
session=session,
robots=robots,
cache=scraper.ResponseCache(tmp_path / "cache"),
sleeper=sleeper,
)
assert sleeper.waits == [1.0, 1.0, 1.0]
def test_an_off_site_link_is_refused_rather_than_followed(site, session, robots):
"""The detour fixture's Next link points at a host reserved for docs.
It must be refused before any connection is attempted — the assertion on
the server log shows the crawl stopped after the one page it was allowed.
"""
site.reset()
with pytest.raises(scraper.OffSite):
scraper.scrape_catalogue(
site.url_for("/detour/page-1.html"),
session=session,
robots=robots,
sleeper=RecordingSleeper(),
)
assert site.paths() == ["/detour/page-1.html"]
# ---------------------------------------------------------------------------
# Exercise 5 — the cache
# ---------------------------------------------------------------------------
def test_a_second_run_makes_no_page_requests(site, session, robots, tmp_path):
cache_dir = tmp_path / "cache"
start = site.url_for("/catalogue/page-1.html")
first = scraper.scrape_catalogue(
start,
session=session,
robots=robots,
cache=scraper.ResponseCache(cache_dir),
sleeper=RecordingSleeper(),
)
site.reset()
warm = scraper.ResponseCache(cache_dir)
second = scraper.scrape_catalogue(
start, session=session, robots=robots, cache=warm, sleeper=RecordingSleeper()
)
assert site.paths() == []
assert warm.hits == 3
assert warm.misses == 0
assert second == first
def test_a_cache_hit_does_not_pay_the_crawl_delay(site, session, robots, tmp_path):
cache_dir = tmp_path / "cache"
start = site.url_for("/catalogue/page-1.html")
scraper.scrape_catalogue(
start,
session=session,
robots=robots,
cache=scraper.ResponseCache(cache_dir),
sleeper=RecordingSleeper(),
)
sleeper = RecordingSleeper()
scraper.scrape_catalogue(
start,
session=session,
robots=robots,
cache=scraper.ResponseCache(cache_dir),
sleeper=sleeper,
)
assert sleeper.waits == []
def test_the_cache_directory_is_inspectable(site, session, robots, tmp_path):
cache_dir = tmp_path / "cache"
scraper.scrape_catalogue(
site.url_for("/catalogue/page-1.html"),
session=session,
robots=robots,
cache=scraper.ResponseCache(cache_dir),
sleeper=RecordingSleeper(),
)
index = cache_dir / "index.json"
assert index.exists()
import json
mapping = json.loads(index.read_text(encoding="utf-8"))
assert sorted(mapping.values()) == [site.url_for(path) for path in EXPECTED_PAGES]
def test_scraping_without_a_cache_still_works(site, session, robots):
site.reset()
items = scraper.scrape_catalogue(
site.url_for("/catalogue/page-1.html"),
session=session,
robots=robots,
cache=None,
sleeper=RecordingSleeper(),
)
assert len(items) == EXPECTED_ITEM_COUNT
assert site.paths() == EXPECTED_PAGES
# ---------------------------------------------------------------------------
# Exercise 6 — CSV output
# ---------------------------------------------------------------------------
def test_the_csv_has_a_header_and_one_row_per_item(site, session, robots, tmp_path):
items = scraper.scrape_catalogue(
site.url_for("/catalogue/page-1.html"),
session=session,
robots=robots,
cache=scraper.ResponseCache(tmp_path / "cache"),
sleeper=RecordingSleeper(),
)
out = scraper.write_csv(items, tmp_path / "catalogue.csv")
lines = out.read_text(encoding="utf-8").splitlines()
assert lines[0] == "sku,name,price,stock,source_path"
assert len(lines) == EXPECTED_ITEM_COUNT + 1
def test_the_missing_price_becomes_an_empty_field_not_the_word_none(
site, session, robots, tmp_path
):
items = scraper.scrape_catalogue(
site.url_for("/catalogue/page-1.html"),
session=session,
robots=robots,
cache=scraper.ResponseCache(tmp_path / "cache"),
sleeper=RecordingSleeper(),
)
out = scraper.write_csv(items, tmp_path / "catalogue.csv")
import csv as csv_module
with out.open(newline="", encoding="utf-8") as handle:
rows = list(csv_module.DictReader(handle))
compass = next(row for row in rows if row["sku"] == "NAV-003")
assert compass["price"] == ""
assert "None" not in out.read_text(encoding="utf-8")
def test_the_ampersand_survives_the_round_trip(site, session, robots, tmp_path):
items = scraper.scrape_catalogue(
site.url_for("/catalogue/page-1.html"),
session=session,
robots=robots,
cache=scraper.ResponseCache(tmp_path / "cache"),
sleeper=RecordingSleeper(),
)
out = scraper.write_csv(items, tmp_path / "catalogue.csv")
import csv as csv_module
with out.open(newline="", encoding="utf-8") as handle:
rows = list(csv_module.DictReader(handle))
quills = next(row for row in rows if row["sku"] == "STA-001")
assert quills["name"] == "Ink & Quill Set"
# ---------------------------------------------------------------------------
# The parser earns its place: a regular expression cannot do this
# ---------------------------------------------------------------------------
NAIVE_NAME_RE = re.compile(r'<td class="name">([^<]*)</td>')
def test_the_naive_regex_misses_rows_the_parser_finds():
html = page_html("page-1.html")
regex_names = [match.group(1) for match in NAIVE_NAME_RE.finditer(html)]
parser_names = [item.name for item in scraper.parse_items(html)]
assert len(parser_names) == 4
assert len(regex_names) == 2
assert "Mariner Astrolabe" in parser_names
assert "Mariner Astrolabe" not in regex_names
def test_the_naive_regex_returns_an_undecoded_entity():
html = page_html("page-1.html")
regex_names = [match.group(1) for match in NAIVE_NAME_RE.finditer(html)]
assert "Ink & Quill Set" in regex_names
parser_names = [item.name for item in scraper.parse_items(html)]
assert "Ink & Quill Set" in parser_names
assert "Ink & Quill Set" not in parser_names
def test_the_naive_regex_returns_unstripped_whitespace():
html = page_html("page-3.html")
regex_names = [match.group(1) for match in NAIVE_NAME_RE.finditer(html)]
assert any(name != name.strip() for name in regex_names)
parser_names = [item.name for item in scraper.parse_items(html)]
assert all(name == name.strip() for name in parser_names)
Troubleshooting
Troubleshooting — Day 079 lab
ModuleNotFoundError: No module named 'bs4'
The distribution is called beautifulsoup4; the module it installs is called
bs4. You install one name and import the other. If pip install beautifulsoup4 succeeded and import bs4 still fails, you installed into a
different interpreter than the one running your code. Check both:
.venv/bin/pip show beautifulsoup4 | head -3
.venv/bin/python3 -c "import bs4, sys; print(bs4.__version__, sys.executable)"
Use .venv/bin/python3 and .venv/bin/pip explicitly, as every command in
this lab does, and the mismatch cannot happen.
ModuleNotFoundError: No module named 'catalogue_scraper'
The example scripts import each other by module name, so Python needs
examples/ on its import path:
PYTHONPATH=examples .venv/bin/python3 examples/demo.py catalogue.csv
tests/run_tests.sh and tests/test_scraper.py set this up for you; only the
direct script invocations need the prefix.
pytest: command not found
tests/run_tests.sh looks for pytest in three places, in order: the PYTEST
environment variable, this lab's .venv/bin/pytest, then your PATH. Create
the venv:
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
Or point the harness at a pytest you already have:
PYTEST=/path/to/pytest bash tests/run_tests.sh
The harness deliberately fails loudly rather than skipping quietly. A test suite that silently does nothing is worse than one that refuses to start.
NotImplementedError: Exercise 3: select_one, check for None, ...
That is the lab working. starter/catalogue_scraper.py ships with six
exercises unwritten, and the message names the one you have reached. Run the
suite against your own module to see how many are left:
SCRAPER_MODULE=starter .venv/bin/pytest tests -q
You should start at 32 failed, 1 passed and finish at 34 passed.
requests.exceptions.ConnectionError or a proxy error on 127.0.0.1
If your machine sets HTTP_PROXY, HTTPS_PROXY or ALL_PROXY, requests
honours them — and may try to route even loopback traffic through a proxy that
is not there. Tell it not to:
NO_PROXY=127.0.0.1 bash tests/run_tests.sh
NO_PROXY tells requests to connect directly to the addresses listed, and
127.0.0.1 is the only address this lab ever connects to.
OSError: [Errno 48] Address already in use
This should be impossible here, and if you see it you have edited
fixture_server.py. The server binds port 0, which asks the operating
system for any free port; a hard-coded port such as 8000 is exactly what
produces this error. Restore the line:
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
The suite hangs, or a stray Python process is left running
serve_fixtures is a context manager with a finally block that calls
shutdown(), server_close() and join(), so a failing test still stops its
server. If you write your own server outside that context manager and a test
raises, you will leak a listener. Find it with:
ps aux | grep fixture-server
and prefer fixing the missing with block over killing processes by hand.
The tests pass but examples/demo.py prints a different port each time
That is correct. The port is assigned at run time. Nothing in the lab prints it, precisely so that captured output stays stable, and nothing in the lab depends on its value.
AttributeError: 'NoneType' object has no attribute 'get_text'
You chained a method onto a select_one that found nothing — the single most
common scraping bug, and the whole subject of Exercise 3. select_one returns
None when there is no match; check for it before touching the result. The
fixture's NAV-003 row has no price cell at all, specifically so this
happens to you here rather than at 3 a.m. on someone else's site.
ValueError: could not convert string to float: ''
The same bug wearing a different hat: you got past the None check but then
called float() on an empty string. Treat "cell absent" and "cell empty" the
same way — both mean "no price", and both should produce None.
The starter suite fails on an import error rather than a NotImplementedError
You have introduced a syntax error or removed an import from
starter/catalogue_scraper.py. Run it directly to see the real message:
.venv/bin/python3 -c "import sys; sys.path.insert(0, 'starter'); import catalogue_scraper"
Windows
Everything here assumes a POSIX shell. Run the lab inside WSL. bash tests/run_tests.sh needs bash; PowerShell will not run it. Outside WSL the
venv paths become .venv\Scripts\python and .venv\Scripts\pip.
Security notes
Security notes — Day 079 lab
-
What this lab does. It starts an HTTP server bound to
127.0.0.1on a port the operating system assigns, serves the HTML files inexamples/fixtures/, scrapes them, writes a CSV, and stops. It makes no connection to any host but the loopback address, needs no privileges, and writes only into the directory you run it from and into temporary directories created withmkdtemp. Installing the three pinned packages is the only step that uses the internet. -
Bound to loopback on purpose.
("127.0.0.1", 0)means the listener is reachable only from this machine. Binding("", 0)or("0.0.0.0", 0)instead would expose the fixture site to your whole local network — on a café Wi-Fi, to everyone in the café. When you write your own test servers, make the interface explicit and make it loopback. -
The ethics ARE the security model here. Rate limiting,
robots.txtand an honestUser-Agentare usually filed under manners. They are also the controls that keep a scraper from being indistinguishable from a denial-of-service tool. A loop with no delay, no cache and no host guard is a stress test that you did not get permission to run, and the operator on the other end has no way to tell the difference between that and an attack. The cache in this lab is the strongest of those controls: it makes the twentieth run of your parser cost the site nothing at all. -
Never send credentials to a site you are scraping. Nothing in this lab authenticates, and that is deliberate. Logging in to reach content changes the legal and contractual picture completely — you have then accepted terms of service, and you are acting as an identified user rather than an anonymous client. If you ever do need a token for an API, read it from the environment as Day 78 showed, never from a literal in the source, and never commit it.
-
robots.txtis advice a client chooses to honour; it is not access control. The fixture server serves/private/internal-notes.htmlto anyone who asks for it. The only reason your scraper never sees it is that your scraper asked first and then declined. Read that in both directions: as a client, honour it; as someone who will one day run a server, never treat aDisallow:line as a security boundary. Anything that must not be read needs authentication, not a line in a text file. -
Parsed HTML is untrusted input. BeautifulSoup builds a tree; it does not execute anything, and
html.parseris pure Python with no external parser to exploit. But everything you extract came from someone else's server and must be validated before it is used: a price is a string until you convert it, a link is a string until you check its host, and a name may contain anything at all. This lab converts prices withfloat()inside a guard, refuses links whose netloc differs from the start URL, and writes output with thecsvmodule rather than by joining strings — which is also what keeps a comma or a quote inside a product name from corrupting the file. -
Never interpolate scraped text into a shell command, a SQL query, or a file path. The
csvmodule quotes for you; a database driver's parameter binding quotes for you. String concatenation does not. A scraped value is the textbook case of input you did not write. -
Formulas in CSV are a real hazard. A cell beginning with
=,+,-or@is interpreted as a formula by common spreadsheet applications when the file is opened. If you will ever open scraped CSV in a spreadsheet, prefix such cells or import as text. The fixture data here contains no such values; a real site might. -
A cache directory is a copy of someone else's content on your disk. It inherits every question the original had: how long you may keep it, whether it contains personal data, whether it may be shared or committed. This lab's cache lives in a temporary directory and is deleted when the run ends. If you keep a cache, keep it out of version control, and set yourself an expiry rather than accumulating a private archive of a site you do not own.
-
Personal data raises the stakes and this lab deliberately avoids it. The fixture catalogue contains invented products and no people. "Publicly visible" is not the same as "free to collect, store, and republish": names, photographs, reviews, profiles and posts are personal data in most legal regimes, and collecting them at scale is exactly the activity those regimes are written about. If your scraping target contains people, stop and get advice before you write the loop, not after.
-
Read before you run. Every file in this lab is short and commented.
examples/fixture_server.py,examples/catalogue_scraper.py,examples/demo.pyandtests/run_tests.share all worth reading first. The habit matters more than these particular files: running unread scripts is one of the most common ways developers get compromised. -
This lab is not legal advice, and neither is the lesson. The legal position on scraping varies by jurisdiction, by the terms of the site, by what you collect and by what you do with it, and it changes. What the lesson and this lab can give you is the engineering discipline that keeps you out of the easy trouble, and a clear enough picture of the questions to know when you need a real answer from a real lawyer.