Beautiful soup web scraper python

PYTHON Published Updated 5 mins read By Leon Wei
Beautiful soup web scraper python cover image

Quick summary

Summarize this blog with AI

Beautiful Soup parses HTML; it does not fetch a page or execute browser JavaScript. A reliable small scraper separates retrieval, parsing, validation, and output. This tutorial extracts fictional job cards into a pandas DataFrame and tests the result against HTML we own.

Tested with Python 3.12.14, Beautiful Soup 4.15.0, Requests 2.34.2, and pandas 2.2.3/3.0.6. All demonstrated requests use a temporary local server. Before adapting retrieval to a real site, confirm permission, access rules, rate limits, and whether a supported API is available; this fixture establishes no permission for a third-party site.

Start with an explicit page contract

Each article.job has a unique data-id and a nonempty h2.title. A missing location is allowed and represented as None. A page with no cards is valid only when it explicitly contains p.no-results; otherwise absence is treated as possible layout drift.

from bs4 import BeautifulSoup
import pandas as pd

HTML = """<main>
<article class="job" data-id="j1"><h2 class="title">Data <b>Analyst</b></h2>
<span class="location">Remote</span></article>
<article class="job" data-id="j2"><h2 class="title">Data Engineer</h2></article>
</main>"""

def parse_jobs(html):
    soup = BeautifulSoup(html, "html.parser")
    cards = soup.select("article.job")
    if not cards and not soup.select_one("p.no-results"):
        raise ValueError("neither job cards nor explicit empty marker found")
    if len(cards) > 100:
        raise ValueError("too many job cards")
    rows, seen = [], set()
    for card in cards:
        job_id = card.get("data-id", "").strip()
        title_node = card.select_one("h2.title")
        title = title_node.get_text(" ", strip=True) if title_node else ""
        if not job_id or not title or job_id in seen:
            raise ValueError("missing identity/title or duplicate job ID")
        location_node = card.select_one("span.location")
        location = location_node.get_text(" ", strip=True) if location_node else None
        seen.add(job_id)
        rows.append({"job_id": job_id, "title": title, "location": location or None})
    return rows

jobs = pd.DataFrame(parse_jobs(HTML), columns=["job_id", "title", "location"], dtype=object)
print(jobs.to_dict("records"))
print(parse_jobs('<p class="no-results">No jobs</p>'))

Expected output:

[{'job_id': 'j1', 'title': 'Data Analyst', 'location': 'Remote'}, {'job_id': 'j2', 'title': 'Data Engineer', 'location': None}]
[]

get_text with a separator keeps nested words from becoming DataAnalyst. Optional fields remain visibly missing rather than being invented. The explicit object dtype retains None consistently across the two tested pandas versions. The DataFrame column list preserves its schema even when there are zero rows. Beautiful Soup may repair malformed HTML; that does not prove the repaired structure satisfies your data contract.

Retrieve a bounded UTF-8 HTML response

import requests
from urllib.parse import urlsplit

def fetch_html(url, max_bytes=65536):
    parsed = urlsplit(url)
    local = parsed.scheme == "http" and parsed.hostname == "127.0.0.1"
    if (parsed.scheme != "https" and not local) or not parsed.hostname:
        raise ValueError("use HTTPS or the local fixture server")
    if parsed.username is not None or parsed.password is not None:
        raise ValueError("credentials must not be embedded in the URL")
    if type(max_bytes) is not int or max_bytes < 1:
        raise ValueError("max_bytes must be a positive integer")
    with requests.Session() as session:
        session.trust_env = False
        with session.get(url, timeout=(5, 15), stream=True,
                         allow_redirects=False,
                         headers={"Accept-Encoding": "identity"}) as response:
            response.raise_for_status()
            if response.status_code != 200:
                raise ValueError("expected HTTP 200 without redirects")
            media = response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
            if media != "text/html":
                raise ValueError("expected HTML")
            if response.headers.get("Content-Encoding", "identity").lower() != "identity":
                raise ValueError("compressed content is not accepted")
            content = bytearray()
            for chunk in response.iter_content(chunk_size=4096):
                if len(content) + len(chunk) > max_bytes:
                    raise ValueError("HTML exceeds byte limit")
                content.extend(chunk)
    return content.decode("utf-8")

The helper accepts caller-owned HTTPS URLs, checks HTTP status and content type, refuses redirects and compressed content, and caps actual received bytes. UTF-8 is an explicit contract; a different declared encoding needs a deliberate implementation. The timeout pair bounds connection setup and socket read inactivity, not the entire download's elapsed time. There are no automatic retries or environment-provided credentials/proxies.

HTTPS validates the connection under the usual Requests certificate rules. It does not make arbitrary user-submitted URLs safe to fetch from a server. This helper is not an SSRF-protected public fetch service; keep target URLs application-owned and apply deployment-level destination controls where needed.

Bound pagination without chasing arbitrary links

def collect_jobs(urls):
    if not 1 <= len(urls) <= 3 or len(set(urls)) != len(urls):
        raise ValueError("supply one to three distinct page URLs")
    origins = {(urlsplit(url).scheme, urlsplit(url).netloc) for url in urls}
    if len(origins) != 1:
        raise ValueError("pages must share one configured origin")
    rows, seen = [], set()
    for url in urls:
        for row in parse_jobs(fetch_html(url)):
            if row["job_id"] in seen:
                raise ValueError("duplicate job ID across pages")
            seen.add(row["job_id"])
            rows.append(row)
    return pd.DataFrame(rows, columns=["job_id", "title", "location"], dtype=object)

The page list is explicit and limited to three pages, each with at most 100 cards and 64 KiB of HTML. Requests run sequentially; a real permitted source may require an additional delay or lower limits. A repeated job across pages is rejected, so pagination drift cannot silently become a duplicated record. This function does not discover the total number of pages; provide the agreed finite list.

Run the entire pipeline against a local fixture

from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread

class JobPage(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path != "/jobs":
            self.send_error(404)
            return
        body = HTML.encode("utf-8")
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)
    def log_message(self, *args):
        pass

server = ThreadingHTTPServer(("127.0.0.1", 0), JobPage)
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
    url = f"http://127.0.0.1:{server.server_port}/jobs"
    print(collect_jobs([url])["job_id"].tolist())
    try:
        fetch_html(url + "/missing")
    except requests.HTTPError:
        print("HTTP error propagated")
finally:
    server.shutdown()
    server.server_close()
    thread.join()

Expected output:

['j1', 'j2']
HTTP error propagated

The server binds only to loopback and uses an assigned temporary port. Cleanup runs even if parsing fails. The empty log_message is only suppressing local access-log noise; it is not an exception handler. In an operational scraper, record request outcomes and parser failures without logging credentials or private page contents.

Fail visibly when a required field disappears

for broken in ['<article class="job" data-id="j1"></article>', '<h1>Login</h1>']:
    try:
        parse_jobs(broken)
    except ValueError:
        print("page contract rejected")

Expected output:

page contract rejected
page contract rejected

Do not treat a login page or changed selector as “zero jobs.” Store a failed run as failed, preserve the last valid output, and investigate the source. For a JavaScript-only page, a static HTTP response may lack the desired data; prefer a permitted API or a separately tested browser workflow rather than adding outdated Selenium selectors to this parser.

References: Beautiful Soup parsing and selectors, Requests timeouts, and streamed response handling.

Interview Prep

Begin Your SQL, Python, and R Journey

Master 230 interview-style coding questions and build the data skills needed for analyst, scientist, and engineering roles.

Related Articles

All Articles
Python first match cover image
python Oct 17, 2026

Python first match

Master Python's First Match for string pattern searches. Efficiently locate the first occurrence of patterns in strings, a skill for log parsing…

Invalid syntax python cover image
python Oct 17, 2026

Invalid syntax python

Discover Python readable and expressive syntax, the language structure that balances simplicity with flexibility, perfect for both beginners and…

Pandas read write files cover image
python Oct 17, 2026

Pandas read write files

Explore the essentials of Pandas for data analysis in Python. Learn how it simplifies data manipulation and analysis with its robust data struct…

Pygame a primer cover image
python Apr 29, 2024

Pygame a primer

Learn how Pygame creates fully-featured games and multimedia applications, and simplifies graphics rendering, sound management, and game logic w…