BeautifulSoup 4 + soupsieve CSS selector scraping: working versions (tested 2026-10-16)
Scrape fixture HTML with BeautifulSoup and soupsieve CSS selectors to pull table cells, links and data attributes, plus the html.parser tbody gotcha.
passed7 passed · 0 failed2026-10-16 21:56 UTC- Status
- passed
- Last passed
- 2026-10-16 21:56 UTC
- Tests
- 7 / 7
- Language
- Python
- Runtime
- Python 3.13.16
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38972656245
The code
Exactly the files that passed. Your agent can pull them with get_recipe("beautifulsoup4-soupsieve-select") (install), or see them on GitHub.
beautifulsoup4==4.15.0 soupsieve==3.0.3 pytest==9.1.1
import soupsieve
from bs4 import BeautifulSoup
HTML = """
<!doctype html>
<html><body>
<table id="projects">
<thead><tr><th>Name</th><th>Owner</th><th>Cost</th><th>Docs</th></tr></thead>
<tbody>
<tr data-status="active">
<td class="name">Apollo</td><td class="owner">Ada</td><td class="cost">10</td>
<td><a href="/docs/apollo.pdf">spec</a></td>
</tr>
<tr data-status="archived">
<td class="name">Borealis</td><td class="owner">Ada</td><td class="cost">20</td>
<td><a href="/docs/borealis.html">site</a></td>
</tr>
<tr data-status="active">
<td class="name">Ceres</td><td class="owner">Bo</td><td class="cost">5</td>
<td><a href="/docs/ceres.pdf">spec</a> <a href="https://ext.example.com/ceres.pdf">mirror</a></td>
</tr>
</tbody>
</table>
<a href="/about" data-status="active">About</a>
</body></html>
"""
def parse(html: str = HTML) -> BeautifulSoup:
return BeautifulSoup(html, "html.parser")
def project_names(soup: BeautifulSoup) -> list[str]:
return [c.get_text(strip=True) for c in soup.select("table#projects tbody tr td.name")]
def pdf_links(soup: BeautifulSoup) -> list[str]:
return [a["href"] for a in soup.select('a[href$=".pdf"]')]
def rows_with_pdf(soup: BeautifulSoup) -> list[str]:
rows = soup.select("tbody tr:has(a[href$='.pdf'])")
return [r.select_one("td.name").get_text(strip=True) for r in rows]
def active_names(soup: BeautifulSoup) -> list[str]:
rows = soup.select("tbody tr[data-status=active]")
return [r.select_one("td.name").get_text(strip=True) for r in rows if soupsieve.match("tr[data-status=active]", r)]
import pytest
import soupsieve
from bs4 import BeautifulSoup
from scrape import active_names, parse, pdf_links, project_names, rows_with_pdf
def test_extracts_table_cells():
assert project_names(parse()) == ["Apollo", "Borealis", "Ceres"]
def test_pdf_links_use_attribute_suffix():
assert pdf_links(parse()) == [
"/docs/apollo.pdf",
"/docs/ceres.pdf",
"https://ext.example.com/ceres.pdf",
]
def test_comma_selector_keeps_document_order():
classes = [c["class"][0] for c in parse().select("td.cost, td.name")]
assert classes == ["name", "cost", "name", "cost", "name", "cost"]
def test_has_finds_rows_with_pdf():
assert rows_with_pdf(parse()) == ["Apollo", "Ceres"]
assert active_names(parse()) == ["Apollo", "Ceres"]
def test_data_attr_is_case_sensitive_unless_flag():
soup = parse()
assert len(soup.select("tbody tr[data-status=ACTIVE]")) == 0
assert len(soup.select("tbody tr[data-status=active i]")) == 2
row = soup.select_one("tbody tr")
assert soupsieve.match("tr[data-status=active]", row)
assert not soupsieve.match("tr[data-status=archived]", row)
def test_html_parser_does_not_insert_tbody():
soup = BeautifulSoup("<table><tr><td>x</td></tr></table>", "html.parser")
assert len(soup.select("table tr")) == 1
assert soup.select("table > tbody > tr") == []
def test_missing_attribute_raises_but_get_is_safe():
cell = parse().select_one("td.name")
assert cell.get("href") is None
with pytest.raises(KeyError):
_ = cell["href"]
[pytest] pythonpath = .
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
Capture loguru logs in pytest caplog via a propagate sink and structured extra
Forward loguru records into stdlib logging so pytest caplog sees them, keep bound context as structured extra, and test it offline without a real log backend.
FastAPI dependency_overrides tested offline with httpx ASGITransport
Override FastAPI dependencies (including nested ones) and drive the app in-process with httpx.ASGITransport, no network and no running server.
FastAPI metrics with prometheus_client scraped as text
Instrument FastAPI with prometheus_client using a per-app registry, label by route template to bound cardinality, and scrape the text exposition format offline through httpx.ASGITransport.
FastAPI metrics with prometheus-fastapi-instrumentator tested offline
Instrument a FastAPI app with the default prometheus-fastapi-instrumentator metrics, scrape /metrics through httpx.ASGITransport, and cover the route-template labels, untemplated grouping, self-scraping and shared-registry gotchas.
All beautifulsoup4 recipesAll soupsieve recipesAll pytest recipes