Scrape Product Comparisons from CNET

CNET comparison pages are useful because they already do part of the data modeling for you. They group products side by side, label the important specs, and often include short verdict language you can reuse for research.

That makes them a strong source for:

  • competitor comparison datasets
  • internal buying guides
  • feature matrices
  • trend monitoring across product categories

In this guide we will build a Python scraper that:

  • fetches a CNET comparison page
  • extracts product names from the headline and page body
  • captures HTML tables when they are present
  • saves verdict or summary paragraphs
  • normalizes the output to JSON and CSV-friendly rows
  • optionally routes requests through ProxiesAPI

CNET Versus page

Keep comparison crawls reliable with ProxiesAPI

Editorial sites look stable until your scraper runs at volume. ProxiesAPI gives your fetch layer a cleaner path when retries and block handling start to matter.


What we are scraping

The safest CNET entry point for comparison content is:

  • https://www.cnet.com/versus/

From there you can discover category pages or individual comparison stories.

In practice, CNET pages usually give you three scrapeable layers:

  1. page metadata in JSON-LD
  2. visible headings and summaries in the article body
  3. one or more HTML tables containing spec rows

That means a generic parser can go surprisingly far before you need category-specific selectors.


Setup

python3 -m venv .venv
source .venv/bin/activate
pip install requests beautifulsoup4 lxml pandas tenacity

Step 1: Create a fetch client with retries

import os
import random
from dataclasses import dataclass

import requests
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential_jitter

TIMEOUT = (10, 30)
USER_AGENTS = [
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
]


class RetryableFetchError(Exception):
    pass


@dataclass
class FetchResult:
    url: str
    status_code: int
    text: str


def proxiesapi_proxy() -> dict[str, str] | None:
    key = os.getenv("PROXIESAPI_KEY")
    if not key:
        return None
    proxy = f"http://{key}:@proxy.proxiesapi.com:10000"
    return {"http": proxy, "https": proxy}


@retry(
    reraise=True,
    stop=stop_after_attempt(5),
    wait=wait_exponential_jitter(initial=1, max=20),
    retry=retry_if_exception_type(RetryableFetchError),
)
def fetch_html(session: requests.Session, url: str, use_proxiesapi: bool = False) -> FetchResult:
    headers = {
        "User-Agent": random.choice(USER_AGENTS),
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
        "Accept-Language": "en-US,en;q=0.9",
    }
    response = session.get(url, headers=headers, timeout=TIMEOUT, proxies=proxiesapi_proxy() if use_proxiesapi else None)

    if response.status_code in (403, 429, 500, 502, 503, 504):
        raise RetryableFetchError(f"retryable status {response.status_code}")

    response.raise_for_status()
    return FetchResult(url=url, status_code=response.status_code, text=response.text)

Step 2: Parse page metadata and product names

CNET exposes helpful metadata in JSON-LD, and the visible headline often contains the comparison framing.

import json
import re
from bs4 import BeautifulSoup


def clean_text(value: str) -> str:
    return re.sub(r"\s+", " ", value).strip()


def load_jsonld(soup: BeautifulSoup) -> list[dict]:
    items = []
    for script in soup.select('script[type="application/ld+json"]'):
        raw = script.get_text(strip=True)
        if not raw:
            continue
        try:
            loaded = json.loads(raw)
        except json.JSONDecodeError:
            continue
        if isinstance(loaded, dict) and "@graph" in loaded:
            items.extend(obj for obj in loaded["@graph"] if isinstance(obj, dict))
        elif isinstance(loaded, dict):
            items.append(loaded)
    return items


def extract_page_metadata(html: str) -> dict:
    soup = BeautifulSoup(html, "lxml")
    jsonld = load_jsonld(soup)

    headline = clean_text(soup.select_one("h1").get_text(" ", strip=True)) if soup.select_one("h1") else None
    description = None

    for item in jsonld:
        if item.get("@type") in {"Article", "WebPage"}:
            description = description or item.get("description")

    product_names = []
    if headline and " vs " in headline.lower():
        product_names = [clean_text(part) for part in re.split(r"\bvs\.?\b", headline, flags=re.I) if clean_text(part)]

    return {
        "headline": headline,
        "description": description,
        "product_names": product_names,
    }

This gets you a clean baseline even on pages that are light on tables.


Step 3: Extract comparison tables

When a page renders a normal HTML table, parse it generically instead of hard-coding every spec row.

def html_table_to_rows(table) -> list[list[str]]:
    rows = []
    for tr in table.select("tr"):
        cells = [clean_text(cell.get_text(" ", strip=True)) for cell in tr.select("th, td")]
        if any(cells):
            rows.append(cells)
    return rows


def extract_tables(html: str) -> list[dict]:
    soup = BeautifulSoup(html, "lxml")
    tables = []

    for index, table in enumerate(soup.select("table")):
        rows = html_table_to_rows(table)
        if len(rows) < 2:
            continue

        caption = None
        caption_node = table.find_previous(["h2", "h3"])
        if caption_node:
            caption = clean_text(caption_node.get_text(" ", strip=True))

        tables.append({
            "index": index,
            "caption": caption,
            "rows": rows,
        })

    return tables

This strategy is robust because it does not assume CNET uses one perfect template forever.


Step 4: Capture verdict text and summary blocks

Comparison pages are not only about specs. The verdict language is often what you actually want for internal research.

def extract_summary_blocks(html: str) -> list[str]:
    soup = BeautifulSoup(html, "lxml")
    blocks = []

    for node in soup.select("main p, article p"):
        text = clean_text(node.get_text(" ", strip=True))
        if len(text) < 80:
            continue
        if text in blocks:
            continue
        blocks.append(text)

    return blocks[:12]

Now you have:

  • product names
  • table rows
  • narrative summaries

That is enough to support most research or internal comparison use cases.


Step 5: Normalize the dataset

import pandas as pd


def scrape_cnet_comparison(url: str, use_proxiesapi: bool = False) -> dict:
    session = requests.Session()
    result = fetch_html(session, url, use_proxiesapi=use_proxiesapi)

    metadata = extract_page_metadata(result.text)
    tables = extract_tables(result.text)
    summaries = extract_summary_blocks(result.text)

    return {
        "source_url": url,
        "headline": metadata["headline"],
        "description": metadata["description"],
        "product_names": metadata["product_names"],
        "summaries": summaries,
        "tables": tables,
    }


def flatten_tables(scrape_result: dict) -> list[dict]:
    rows = []
    for table in scrape_result["tables"]:
        for row in table["rows"][1:]:
            rows.append({
                "source_url": scrape_result["source_url"],
                "headline": scrape_result["headline"],
                "table_caption": table["caption"],
                "spec_name": row[0] if row else None,
                "values": row[1:],
            })
    return rows


if __name__ == "__main__":
    data = scrape_cnet_comparison("https://www.cnet.com/versus/", use_proxiesapi=False)

    with open("cnet_comparison.json", "w", encoding="utf-8") as fh:
        json.dump(data, fh, ensure_ascii=False, indent=2)

    pd.DataFrame(flatten_tables(data)).to_csv("cnet_comparison_rows.csv", index=False)
    print("wrote cnet_comparison.json and cnet_comparison_rows.csv")

Discovering more comparison pages

Once your parser works on one page, you can crawl more URLs from the CNET Versus hub:

from urllib.parse import urljoin


def discover_comparison_urls(html: str, base_url: str = "https://www.cnet.com") -> list[str]:
    soup = BeautifulSoup(html, "lxml")
    seen = set()
    urls = []

    for a in soup.select("a[href]"):
        href = a.get("href", "")
        if "/versus/" not in href:
            continue
        absolute = urljoin(base_url, href)
        if absolute in seen:
            continue
        seen.add(absolute)
        urls.append(absolute)

    return urls

Keep the discovery and parsing stages separate. That makes it easier to test and easier to recover when one page template changes.


Where ProxiesAPI helps

For a single tutorial page, direct requests are usually enough. ProxiesAPI becomes useful when:

  • you collect many comparison pages in one run
  • you schedule recurring refreshes
  • you start seeing intermittent block or throttling behavior

The fetch layer above keeps that upgrade simple:

data = scrape_cnet_comparison(url, use_proxiesapi=True)

No parser rewrite required.


Final takeaway

CNET comparison content is valuable because it already blends:

  • structured spec rows
  • editorial summaries
  • clear product framing

If you separate your scraper into fetch, parse, and normalize steps, you get a maintainable dataset builder instead of a brittle one-off script.

Keep comparison crawls reliable with ProxiesAPI

Editorial sites look stable until your scraper runs at volume. ProxiesAPI gives your fetch layer a cleaner path when retries and block handling start to matter.

Keep comparison crawls reliable with ProxiesAPI

Editorial sites look stable until your scraper runs at volume. ProxiesAPI gives your fetch layer a cleaner path when retries and block handling start to matter.

Related guides

Scrape Podcast Data from Apple Podcasts with Python (Charts + Show Metadata)
Build a scraper that captures Apple Podcasts chart listings, show metadata, and episode links into a clean discovery dataset, with an optional ProxiesAPI request layer for scheduled crawls.
tutorial#python#apple-podcasts#podcasts
Scrape Government Contract Data from SAM.gov
Build a SAM.gov opportunities dataset in Python: search with filters, paginate results, follow detail pages, and export structured contract fields with retries and polite crawling.
tutorial#python#sam-gov#government-contracts
Scrape Book Data from Goodreads
Build a Goodreads dataset with book titles, authors, ratings, and review counts from a public list page using Python and an optional ProxiesAPI fetch layer.
tutorial#python#goodreads#books
Scrape Secondhand Fashion Listings from Vinted
Show how to collect Vinted search listings, prices, brands, and image URLs into a resale market dataset with Python and an optional ProxiesAPI fetch layer.
tutorial#python#vinted#web-scraping