Most "learn web scraping" tutorials stop at printing a few titles to the terminal. That never felt like a finished tool to me, so I built one that goes all the way: scrape a catalogue, clean the data, and hand over a formatted Excel report someone could actually use.
The stack is plain Python: requests, BeautifulSoup, pandas, and openpyxl. No frameworks, no paid APIs. The target is books.toscrape.com, a site built specifically for practising scraping — so everything here is on a public practice page, not a real store.
What you end up with
Running two scripts produces:
-
data/books_raw.csv— the raw scrape, one row per book -
data/books_clean.csv— the same data, cleaned -
data/books_clean.xlsx— a formatted Excel report: bold headers, filters, a frozen top row, and sensible column widths
The full run collects 1,000 books, and the cleaned report's average price comes out at £35.07.
Step 0: Setup
pip install requests beautifulsoup4 pandas openpyxl
Three files: scraper.py (collect), clean_export.py (clean + export), and a data/ folder for the outputs.
Step 1: Scrape every page, not just the first one
A catalogue scraper that only reads page 1 is a demo, not a tool. The real job is walking the pagination until there is no "next" link left. The core loop does exactly that:
def scrape(max_pages=None):
all_books = []
page = 1
while True:
url = CATALOGUE_URL.format(page)
response = requests.get(url, headers=HEADERS, timeout=20)
response.raise_for_status()
response.encoding = "utf-8"
books, has_next = parse_page(response.text, url)
all_books.extend(books)
if not has_next:
break
page += 1
if max_pages and page > max_pages:
break
time.sleep(0.2) # be polite to the server
return all_books
Each page is parsed with one function that pulls five fields out of every book card:
def parse_page(html, page_url):
soup = BeautifulSoup(html, "html.parser")
books = []
for article in soup.select("article.product_pod"):
title = article.h3.a["title"].strip()
price_text = article.select_one("p.price_color").get_text(strip=True)
rating_word = article.select_one("p.star-rating")["class"][1]
availability = article.select_one("p.instock").get_text(strip=True)
rel_url = article.h3.a["href"]
books.append({
"title": title,
"price": price_text,
"rating": rating_word,
"availability": availability,
"url": urljoin(page_url, rel_url),
})
has_next = soup.select_one("li.next a") is not None
return books, has_next
Three details worth calling out, because each one bit me or would have:
-
The
£encoding gotcha. This site doesn't declare its charset, sorequestsguesses Latin-1 and corrupts the£sign. The pages are actually UTF-8, so one line —response.encoding = "utf-8"— fixes it before parsing. If your scraped prices ever look like£51.77, this is almost certainly why. -
Be polite. A
0.2second sleep between pages and an honestUser-Agent(python-automation-toolkit/1.0 (learning project)) cost nothing and keep you from hammering someone else's server. Scraping fast is not a skill; scraping without getting blocked is. -
Relative URLs. Book links on the page are relative, so
urljoin(page_url, rel_url)turns them into full URLs you can actually click later.
Run it with python scraper.py (or --pages 5 for a quick test run), and you get data/books_raw.csv with 1,000 rows.
Step 2: Clean the data before anyone sees it
Raw scraped data is never report-ready. Prices are strings with currency symbols (£51.77), ratings are words (Three), and availability is a sentence fragment. The cleaning step turns all of that into typed, analysable data:
RATING_MAP = {"One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5}
def clean(df):
df = df.copy()
# "£51.77" -> 51.77 (drop anything that is not a digit or a dot)
df["price"] = df["price"].astype(str).str.replace(r"[^0-9.]", "", regex=True)
df["price"] = pd.to_numeric(df["price"], errors="coerce")
df = df.dropna(subset=["price"])
# "Three" -> 3
df["rating"] = df["rating"].map(RATING_MAP)
# "In stock" -> True, anything else -> False
df["in_stock"] = df["availability"].str.contains("In stock", na=False)
df = df.drop(columns=["availability"])
df = df.drop_duplicates(subset=["title", "url"])
return df
The pattern here is worth stealing for any scrape: strip, convert with errors="coerce" (bad values become NaN instead of crashing the run), drop the rows that didn't convert, and drop duplicates on a sensible key — here, title + URL.
Step 3: Export an Excel file a human would enjoy opening
A CSV is fine for machines. If a person is going to read the report, formatting matters — and it's only a few lines with openpyxl:
def write_excel(df, path):
with pd.ExcelWriter(path, engine="openpyxl") as writer:
df.to_excel(writer, sheet_name="Books", index=False)
sheet = writer.sheets["Books"]
for cell in sheet[1]:
cell.font = Font(bold=True)
sheet.auto_filter.ref = sheet.dimensions
sheet.freeze_panes = "A2"
widths = {"title": 55, "price": 10, "rating": 10,
"in_stock": 10, "url": 60}
for idx, col in enumerate(df.columns, start=1):
sheet.column_dimensions[get_column_letter(idx)].width = (
widths.get(col, 20)
)
Bold headers, filters, a frozen top row, columns wide enough for the content. That's the difference between "here's a file" and "here's a report."
python clean_export.py also prints a short summary: book count, average price, how many are in stock, and the rating mix.
The result
| Output | Contents |
|---|---|
books_raw.csv |
1,000 books, raw strings |
books_clean.csv |
1,000 books, typed and deduplicated |
books_clean.xlsx |
Same data, formatted and filterable |
Average price across the catalogue: £35.07.
Three lessons I'd pass on
- Scrape practice sites to learn. Real sites have terms of service, rate limits, and anti-bot measures. Learn the mechanics on a site built for it, and only scrape public pages with permission when real data is involved.
- The pipeline is the project. Scraping is the easy third. Cleaning and presenting the data is where a script becomes a tool someone would pay for.
- Small, polite, honest scripts beat clever ones. A 0.2 second delay and a real User-Agent never got anyone blocked; pretending to be a browser did.
The full code is in my Python Automation Toolkit project, and the same scrape → clean → report pattern works for any tabular listing: products, jobs, or prices. Next, I'll write about the other half of the toolkit — a file organiser that tidies a messy downloads folder without ever deleting anything by default.
Malik Ali Hassan — .NET & full-stack developer, final-year Software Engineering student. I build Python automation, AI assistants, and web apps.
Top comments (0)