Skip to content

Commit b1122ac

Browse files
Initial commit: tested Python web scraping examples
Companion code for python-web-scraping.com. Five self-contained example modules (parsing, regex extraction, resilient HTTP session, validate+store, async fetching) each with tests. CI runs ruff + pytest on Python 3.10-3.13; tests are deterministic and offline (local fixtures + httpx MockTransport).
0 parents  commit b1122ac

16 files changed

Lines changed: 508 additions & 0 deletions

.github/workflows/ci.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
test:
10+
runs-on: ubuntu-latest
11+
strategy:
12+
fail-fast: false
13+
matrix:
14+
python-version: ["3.10", "3.11", "3.12", "3.13"]
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: actions/setup-python@v5
18+
with:
19+
python-version: ${{ matrix.python-version }}
20+
cache: pip
21+
- name: Install
22+
run: pip install -e ".[dev]"
23+
- name: Lint (ruff)
24+
run: ruff check .
25+
- name: Test (pytest)
26+
run: pytest

.gitignore

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Python
2+
__pycache__/
3+
*.py[cod]
4+
*.egg-info/
5+
.eggs/
6+
build/
7+
dist/
8+
9+
# Virtual environments
10+
.venv/
11+
venv/
12+
env/
13+
14+
# Test / tooling caches
15+
.pytest_cache/
16+
.ruff_cache/
17+
.coverage
18+
htmlcov/
19+
20+
# OS / editor
21+
.DS_Store
22+
*~
23+
.idea/
24+
.vscode/

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Python Web Scraping (https://python-web-scraping.com)
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Python Web Scraping — Examples
2+
3+
[![CI](https://github.com/python-web-scraping-com/examples/actions/workflows/ci.yml/badge.svg)](https://github.com/python-web-scraping-com/examples/actions/workflows/ci.yml)
4+
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
5+
6+
Runnable, **tested** Python web scraping examples — the companion code for
7+
[python-web-scraping.com](https://python-web-scraping.com).
8+
9+
Every example is a small, self-contained function with a test. Each module maps
10+
to a guide on the site, so you can read the explanation there and run the code here.
11+
12+
## Quick start
13+
14+
```bash
15+
git clone https://github.com/python-web-scraping-com/examples.git
16+
cd examples
17+
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
18+
pip install -e ".[dev]"
19+
```
20+
21+
Run any example directly:
22+
23+
```bash
24+
python -m pws_examples.parsing
25+
python -m pws_examples.storage
26+
python -m pws_examples.async_scrape # this one makes real HTTP requests
27+
```
28+
29+
Run the tests and linter:
30+
31+
```bash
32+
pytest
33+
ruff check .
34+
```
35+
36+
## What's inside
37+
38+
| Module | What it shows | Guide |
39+
| --- | --- | --- |
40+
| `pws_examples/parsing.py` | Parse product cards and HTML tables with BeautifulSoup | [Parsing HTML with BeautifulSoup](https://python-web-scraping.com/the-complete-guide-to-python-web-scraping/parsing-html-with-beautifulsoup/) |
41+
| `pws_examples/extraction.py` | Extract emails and phone numbers with regex | [Extracting Data with Regular Expressions](https://python-web-scraping.com/the-complete-guide-to-python-web-scraping/extracting-data-with-regular-expressions/) |
42+
| `pws_examples/http_client.py` | A `requests` session with retries and backoff | [Understanding HTTP Requests and Responses](https://python-web-scraping.com/the-complete-guide-to-python-web-scraping/understanding-http-requests-and-responses/) |
43+
| `pws_examples/storage.py` | Validate with Pydantic, store in SQLite, de-duplicate | [Storing and Exporting Scraped Data](https://python-web-scraping.com/scaling-python-web-scrapers/storing-and-exporting-scraped-data/) |
44+
| `pws_examples/async_scrape.py` | Concurrent fetching with asyncio + HTTPX and a semaphore | [Asynchronous Scraping with asyncio and HTTPX](https://python-web-scraping.com/scaling-python-web-scrapers/asynchronous-scraping-with-asyncio-and-httpx/) |
45+
46+
The tests use local HTML fixtures and `httpx.MockTransport`, so the suite is
47+
deterministic and runs offline — no live websites are hit in CI.
48+
49+
## Scrape responsibly
50+
51+
These examples are for learning. When scraping real sites, respect `robots.txt`,
52+
rate-limit your requests, identify your client honestly, and follow each site's
53+
terms of service.
54+
55+
## Contributing
56+
57+
Contributions are welcome. Please keep each example small and focused, add a test
58+
for it, and make sure `pytest` and `ruff check .` pass before opening a PR.
59+
60+
## License
61+
62+
[MIT](LICENSE) © Python Web Scraping

pws_examples/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Runnable, tested Python web scraping examples for python-web-scraping.com.
2+
3+
Each module is self-contained and maps to a guide on the site. Run any module
4+
directly to see it work, e.g. ``python -m pws_examples.parsing``.
5+
"""
6+
7+
__version__ = "0.1.0"

pws_examples/async_scrape.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Asynchronous scraping with asyncio + HTTPX, bounded by a semaphore.
2+
3+
Companion code for:
4+
- https://python-web-scraping.com/scaling-python-web-scrapers/asynchronous-scraping-with-asyncio-and-httpx/
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import asyncio
10+
11+
import httpx
12+
13+
14+
async def _fetch(client: httpx.AsyncClient, url: str, semaphore: asyncio.Semaphore) -> str | None:
15+
async with semaphore:
16+
try:
17+
response = await client.get(url, timeout=10)
18+
response.raise_for_status()
19+
return response.text
20+
except httpx.HTTPError:
21+
return None
22+
23+
24+
async def scrape(
25+
urls: list[str],
26+
concurrency: int = 5,
27+
transport: httpx.AsyncBaseTransport | None = None,
28+
) -> list[str | None]:
29+
"""Fetch many URLs concurrently, capping in-flight requests with a semaphore.
30+
31+
Failed requests come back as ``None`` rather than cancelling the batch. The
32+
``transport`` parameter lets tests inject ``httpx.MockTransport`` for
33+
deterministic, offline runs.
34+
"""
35+
semaphore = asyncio.Semaphore(concurrency)
36+
async with httpx.AsyncClient(transport=transport) as client:
37+
tasks = [_fetch(client, url, semaphore) for url in urls]
38+
return await asyncio.gather(*tasks)
39+
40+
41+
if __name__ == "__main__":
42+
targets = [f"https://books.toscrape.com/catalogue/page-{i}.html" for i in range(1, 4)]
43+
pages = asyncio.run(scrape(targets))
44+
print("fetched:", sum(page is not None for page in pages))

pws_examples/extraction.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Text extraction with regular expressions.
2+
3+
Companion code for:
4+
- https://python-web-scraping.com/the-complete-guide-to-python-web-scraping/extracting-data-with-regular-expressions/
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import re
10+
11+
EMAIL_RE = re.compile(r"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+")
12+
PHONE_RE = re.compile(r"\b(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b")
13+
14+
15+
def extract_contacts(text: str) -> dict[str, list[str]]:
16+
"""Return de-duplicated, sorted emails and phone numbers found in free text."""
17+
emails = sorted(set(EMAIL_RE.findall(text)))
18+
phones = sorted(set(PHONE_RE.findall(text)))
19+
return {"emails": emails, "phones": phones}
20+
21+
22+
if __name__ == "__main__":
23+
print(extract_contacts("Reach us at info@python-web-scraping.com or +1 (555) 123-4567."))

pws_examples/http_client.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""A resilient HTTP session with retries and exponential backoff.
2+
3+
Companion code for:
4+
- https://python-web-scraping.com/the-complete-guide-to-python-web-scraping/understanding-http-requests-and-responses/
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import requests
10+
from requests.adapters import HTTPAdapter
11+
from urllib3.util.retry import Retry
12+
13+
DEFAULT_USER_AGENT = (
14+
"Mozilla/5.0 (compatible; pws-examples/1.0; +https://python-web-scraping.com)"
15+
)
16+
17+
18+
def build_session(retries: int = 3, backoff_factor: float = 1.0) -> requests.Session:
19+
"""Create a ``requests.Session`` that retries transient failures politely.
20+
21+
Retries ``429`` and ``5xx`` responses on idempotent methods with exponential
22+
backoff, and sets a transparent, identifying User-Agent.
23+
"""
24+
session = requests.Session()
25+
retry = Retry(
26+
total=retries,
27+
backoff_factor=backoff_factor,
28+
status_forcelist=[429, 500, 502, 503, 504],
29+
allowed_methods=["GET", "HEAD"],
30+
)
31+
adapter = HTTPAdapter(max_retries=retry)
32+
session.mount("https://", adapter)
33+
session.mount("http://", adapter)
34+
session.headers.update({"User-Agent": DEFAULT_USER_AGENT})
35+
return session
36+
37+
38+
if __name__ == "__main__":
39+
session = build_session()
40+
adapter = session.get_adapter("https://example.com")
41+
print("Session ready, total retries:", adapter.max_retries.total)

pws_examples/parsing.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""HTML parsing with BeautifulSoup.
2+
3+
Companion code for:
4+
- https://python-web-scraping.com/the-complete-guide-to-python-web-scraping/parsing-html-with-beautifulsoup/
5+
- https://python-web-scraping.com/the-complete-guide-to-python-web-scraping/understanding-http-requests-and-responses/step-by-step-guide-to-extracting-tables-from-html/
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from bs4 import BeautifulSoup
11+
12+
13+
def parse_products(html: str) -> list[dict]:
14+
"""Extract product cards (title + price) from a listing page's HTML."""
15+
soup = BeautifulSoup(html, "lxml")
16+
products: list[dict] = []
17+
for card in soup.select("article.product_pod"):
18+
title = card.select_one("h3 a")
19+
price = card.select_one("p.price_color")
20+
if title and price:
21+
products.append(
22+
{
23+
"title": title.get("title") or title.get_text(strip=True),
24+
"price": price.get_text(strip=True),
25+
}
26+
)
27+
return products
28+
29+
30+
def extract_table(html: str) -> list[dict]:
31+
"""Convert the first ``<table>`` into a list of row dicts keyed by header."""
32+
soup = BeautifulSoup(html, "lxml")
33+
table = soup.find("table")
34+
if table is None:
35+
return []
36+
headers = [th.get_text(strip=True) for th in table.select("thead th")]
37+
rows: list[dict] = []
38+
for tr in table.select("tbody tr"):
39+
cells = [td.get_text(strip=True) for td in tr.find_all("td")]
40+
if cells:
41+
rows.append(dict(zip(headers, cells, strict=False)))
42+
return rows
43+
44+
45+
if __name__ == "__main__":
46+
demo = (
47+
'<article class="product_pod"><h3><a title="Clean Code">Clean Code</a></h3>'
48+
'<p class="price_color">£42.00</p></article>'
49+
)
50+
print(parse_products(demo))

pws_examples/storage.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Validate scraped records and store them, de-duplicated, in SQLite.
2+
3+
Companion code for:
4+
- https://python-web-scraping.com/scaling-python-web-scrapers/storing-and-exporting-scraped-data/
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import sqlite3
10+
11+
from pydantic import BaseModel, ValidationError, field_validator
12+
13+
14+
class Product(BaseModel):
15+
"""A validated product record. ``price`` is coerced from strings like '£42.00'."""
16+
17+
title: str
18+
price: float
19+
in_stock: bool = True
20+
21+
@field_validator("price", mode="before")
22+
@classmethod
23+
def _clean_price(cls, value: object) -> float:
24+
return float(str(value).replace("£", "").replace("$", "").strip())
25+
26+
27+
def validate(rows: list[dict]) -> list[Product]:
28+
"""Validate raw dict rows into ``Product`` models, skipping malformed ones."""
29+
products: list[Product] = []
30+
for row in rows:
31+
try:
32+
products.append(Product(**row))
33+
except ValidationError:
34+
continue
35+
return products
36+
37+
38+
def store_sqlite(products: list[Product], conn: sqlite3.Connection) -> int:
39+
"""Create the table if needed and insert, de-duplicating by title.
40+
41+
Returns the number of rows actually inserted.
42+
"""
43+
conn.execute(
44+
"CREATE TABLE IF NOT EXISTS products ("
45+
"title TEXT UNIQUE, price REAL, in_stock INTEGER)"
46+
)
47+
before = conn.total_changes
48+
conn.executemany(
49+
"INSERT OR IGNORE INTO products (title, price, in_stock) VALUES (?, ?, ?)",
50+
[(p.title, p.price, int(p.in_stock)) for p in products],
51+
)
52+
conn.commit()
53+
return conn.total_changes - before
54+
55+
56+
if __name__ == "__main__":
57+
connection = sqlite3.connect(":memory:")
58+
records = validate([{"title": "Clean Code", "price": "£42.00"}, {"title": "no price"}])
59+
print("stored:", store_sqlite(records, connection))

0 commit comments

Comments
 (0)