Python Data Collection: Scrape, Validate, Pivot, Export Safely

CAREER Published Updated 5 mins read By Leon Wei
Python Data Collection: Scrape, Validate, Pivot, Export Safely cover image

Quick summary

Summarize this blog with AI

A useful automation has one clear input, a validated transformation, and an observable output. This tutorial fetches a small HTML price table, converts integer prices into a pandas pivot, previews a CSV destination, and creates the output without replacing an existing file. It never moves arbitrary files or submits a remote form.

Tested on macOS with Python 3.12.14, Beautiful Soup 4.15.0, Requests 2.34.2, and pandas 2.2.3/3.0.6. Network tests use a local fixture. File publication uses a same-directory hard link, tested on the local filesystem; unsupported filesystems raise an error. Use this as a controlled workflow, with permission for any real source you later configure.

1. Fetch only the configured bounded page

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")

This helper has explicit connect/read timeouts, status checks, a 64 KiB byte cap, no redirects, and UTF-8 decoding. It does not retry, execute JavaScript, follow discovered URLs, or use environment credentials. Its timeout is a socket-inactivity limit rather than a whole-job deadline. Keep targets application-owned: accepting arbitrary external URLs would require a separate SSRF and access-control design.

2. Validate the source rows before reshaping

from bs4 import BeautifulSoup
import pandas as pd
import re

PRICES = """<table id="prices">
<tr data-sku="book" data-store="east"><td class="cents">1200</td></tr>
<tr data-sku="book" data-store="west"><td class="cents">1300</td></tr>
<tr data-sku="game" data-store="east"><td class="cents">2500</td></tr>
</table>"""

def price_frame(html):
    soup = BeautifulSoup(html, "html.parser")
    table = soup.select_one("table#prices")
    if table is None:
        raise ValueError("price table missing")
    nodes = table.select("tr")
    if not 1 <= len(nodes) <= 100:
        raise ValueError("expected one to 100 price rows")
    rows, seen = [], set()
    for node in nodes:
        sku = node.get("data-sku", "")
        store = node.get("data-store", "")
        cell = node.select_one("td.cents")
        raw = cell.get_text(strip=True) if cell else ""
        if not re.fullmatch(r"[a-z0-9_-]{1,30}", sku) or store not in {"east", "west"}:
            raise ValueError("invalid SKU or store")
        if not re.fullmatch(r"[0-9]{1,8}", raw):
            raise ValueError("price must be integer cents")
        if (sku, store) in seen:
            raise ValueError("duplicate SKU/store price")
        seen.add((sku, store))
        rows.append({"sku": sku, "store": store, "cents": int(raw)})
    return pd.DataFrame(rows)

prices = price_frame(PRICES)
wide = prices.pivot(index="sku", columns="store", values="cents")
wide = wide.reindex(columns=["east", "west"]).sort_index().astype("Int64")
print(wide.to_csv(index=True, na_rep="UNKNOWN", lineterminator="\n"), end="")

Expected output:

sku,east,west
book,1200,1300
game,2500,UNKNOWN

pivot requires unique SKU/store cells; the parser rejects duplicates instead of choosing an unexplained average or maximum. Missing west/game remains unknown, not zero. Prices are integer cents in one agreed currency. A human-formatted value such as $12.00 is rejected because accepting it would require a separate locale/currency parser.

3. Preview and create one output atomically

The caller supplies a trusted destination path in an existing directory. The function defaults to a dry run. It never overwrites an existing destination; a repeated scheduled run needs an explicit filename or reconciliation policy.

from pathlib import Path
from tempfile import NamedTemporaryFile, TemporaryDirectory
import os

def publish_csv(frame, destination, dry_run=True):
    destination = Path(destination)
    if not destination.parent.is_dir():
        raise FileNotFoundError("destination directory must already exist")
    if destination.exists() or destination.is_symlink():
        raise FileExistsError("destination already exists")
    if dry_run:
        return "would create " + destination.name
    temporary = None
    try:
        with NamedTemporaryFile(mode="w", encoding="utf-8", newline="",
                                dir=destination.parent, prefix=".prices-", delete=False) as handle:
            temporary = Path(handle.name)
            frame.to_csv(handle, index=True, na_rep="UNKNOWN", lineterminator="\n")
            handle.flush()
            os.fsync(handle.fileno())
        os.link(temporary, destination)
        return "created " + destination.name
    finally:
        if temporary is not None:
            temporary.unlink(missing_ok=True)

with TemporaryDirectory() as directory:
    destination = Path(directory) / "prices.csv"
    print(publish_csv(wide, destination))
    print(destination.exists())
    print(publish_csv(wide, destination, dry_run=False))
    print(destination.read_text(encoding="utf-8").splitlines())
    try:
        publish_csv(wide, destination, dry_run=False)
    except FileExistsError:
        print("existing output preserved")

Expected output:

would create prices.csv
False
created prices.csv
['sku,east,west', 'book,1200,1300', 'game,2500,UNKNOWN']
existing output preserved

The complete temporary file is linked into place only after writing succeeds. Unlike a prior exists check alone, os.link fails if another writer creates the destination in the meantime. The staging file is removed on either success or failure. This relies on hard-link support and a caller-controlled directory; it is not a promise of directory-metadata durability after sudden power loss, or protection against a malicious local directory owner.

4. Run collection through output as one job

def run_collection(url, destination, dry_run=True):
    rows = price_frame(fetch_html(url))
    pivot = rows.pivot(index="sku", columns="store", values="cents")
    pivot = pivot.reindex(columns=["east", "west"]).sort_index().astype("Int64")
    return publish_csv(pivot, destination, dry_run=dry_run)

Retrieval or validation failure propagates before publication. A dry run still fetches and validates the source, but creates no output file. Supply only a permitted configured URL; the parser is demonstrated above on owned HTML, and the complete run was tested against a loopback fixture serving that same HTML. No third-party site, paid API, or authenticated workflow was executed.

5. Schedule only after defining failure and repetition behavior

A scheduler should run the pinned environment, use an absolute configuration/output path, prevent unintended overlapping runs, and record success only after publication succeeds. Do not suppress an HTTP error or layout change and publish an empty report. Choose whether a repeated run is a new dated artifact, an explicitly reviewed replacement, or an idempotent no-op based on content identity; this example deliberately rejects collisions.

Input values here are strictly constrained SKU labels, known store names, and integer amounts. If you later export arbitrary scraped text to a spreadsheet, handle formula-like text according to the target application's rules; CSV quote escaping alone is not a universal spreadsheet-formula defense.

For more parser detail, see the complete Beautiful Soup guide. References: Requests timeouts, pandas pivot, os.link, and temporary files.

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