Skip to content

Python Web Scraping — Beautiful Soup, Scrapy, httpx, and Playwright

DodaTech Updated 2026-06-29 4 min read

In this tutorial, you will learn about Python Web Scraping. We cover key concepts, practical examples, and best practices to help you master this topic.

Web scraping is the art of extracting data from websites. Python's ecosystem offers tools for every scenario: Beautiful Soup for simple HTML parsing, Scrapy for large-scale crawling, httpx for modern HTTP, and Playwright for JavaScript-rendered pages.

DodaTech uses web scraping for reconnaissance — discovering publicly exposed assets, checking TLS certificate validity, and monitoring website changes for security purposes.

What You'll Learn

  • HTTP requests with httpx
  • HTML parsing with Beautiful Soup
  • CSS selectors and XPath
  • Large-scale crawling with Scrapy
  • JavaScript rendering with Playwright
  • Rate Limiting and politeness
  • Legal and ethical considerations

HTTP with httpx

import httpx

# Basic GET
response = httpx.get("https://httpbin.org/json")
print(response.status_code)  # 200
data = response.json()
print(data["slideshow"]["title"])

# With query parameters
params = {"q": "web scraping", "page": 2}
response = httpx.get("https://httpbin.org/get", params=params)
print(response.url)  # Includes ?q=...&page=2

# Custom headers
headers = {
    "User-Agent": "Mozilla/5.0 (compatible; DodaTechBot/1.0)",
    "Accept": "text/html,application/xhtml+xml",
}
response = httpx.get("https://example.com", headers=headers)

# POST with JSON body
response = httpx.post(
    "https://httpbin.org/post",
    json={"query": "search term"}
)

# Timeout and retries
transport = httpx.HTTPTransport(retries=3)
client = httpx.Client(transport=transport, timeout=30.0)

# Async client
async with httpx.AsyncClient() as client:
    response = await client.get("https://example.com")

HTML Parsing with Beautiful Soup

from bs4 import BeautifulSoup
import httpx

response = httpx.get("https://example.com")
soup = BeautifulSoup(response.text, "html.parser")

# Find elements
title = soup.title.text
first_paragraph = soup.find("p").text
all_links = soup.find_all("a")

# CSS selectors
main_content = soup.select_one("div.main-content")
nav_items = soup.select("nav ul li a")

# Extract attributes
for link in soup.select("a[href]"):
    url = link["href"]
    text = link.get_text(strip=True)
    print(f"{text}: {url}")

# Navigating the tree
for child in soup.body.children:
    if child.name == "div":
        print(f"Found div: {child.get('class')}")

# Find by text
results = soup.find_all(string=lambda text: "Python" in text)

Scrapy for Large-Scale Crawling

# pip install scrapyd
# spiders/quotes_spider.py
import scrapy

class QuotesSpider(scrapy.Spider):
    name = "quotes"
    start_urls = ["https://quotes.toscrape.com/page/1/"]

    def parse(self, response):
        for quote in response.css("div.quote"):
            yield {
                "text": quote.css("span.text::text").get(),
                "author": quote.css("small.author::text").get(),
                "tags": quote.css("div.tags a.tag::text").getall(),
            }

        # Follow pagination
        next_page = response.css("li.next a::attr(href)").get()
        if next_page:
            yield response.follow(next_page, self.parse)
# items.py
import scrapy

class ScrapedItem(scrapy.Item):
    url = scrapy.Field()
    title = scrapy.Field()
    content = scrapy.Field()
    scrape_date = scrapy.Field()
# pipelines.py
import json
from datetime import datetime

class JsonPipeline:
    def open_spider(self, spider):
        self.file = open("output.jsonl", "w")

    def close_spider(self, spider):
        self.file.close()

    def process_item(self, item, spider):
        item["scrape_date"] = datetime.now().isoformat()
        self.file.write(json.dumps(dict(item)) + "\n")
        return item

JavaScript Rendering with Playwright

from playwright.sync_api import sync_playwright
import time

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()

    # Navigate and wait for content to render
    page.goto("https://example.com")
    page.wait_for_load_state("networkidle")

    # Wait for specific element
    page.wait_for_selector("div.dynamic-content", timeout=5000)

    # Extract rendered HTML
    content = page.content()
    title = page.title()

    # Interact with page
    page.fill("input#search", "Python")
    page.click("button[type=submit]")
    page.wait_for_load_state("networkidle")

    # Extract results
    results = page.query_selector_all("div.result")
    for result in results:
        print(result.inner_text())

    # Screenshot for debugging
    page.screenshot(path="debug.png")

    browser.close()

Async Playwright

from playwright.async_api import async_playwright
import asyncio

async def scrape_js_pages(urls):
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        context = await browser.new_context(
            user_agent="Mozilla/5.0 (compatible; DodaTechBot/1.0)"
        )

        results = []
        for url in urls:
            page = await context.new_page()
            await page.goto(url, wait_until="networkidle")
            data = await page.evaluate("""
                () => ({
                    title: document.title,
                    h1s: Array.from(document.querySelectorAll('h1')).map(h => h.textContent),
                    links: Array.from(document.querySelectorAll('a[href]')).map(a => a.href)
                })
            """)
            results.append(data)
            await page.close()

        await browser.close()
        return results

Rate Limiting and Politeness

import asyncio
import time
from collections import deque

class PoliteScraper:
    def __init__(self, delay=2.0, max_concurrent=5):
        self.delay = delay
        self.semaphore = asyncio.Semaphore(max_concurrent)
        self.last_request = {}

    async def fetch(self, url, domain):
        async with self.semaphore:
            # Ensure minimum delay between requests to same domain
            now = time.time()
            last = self.last_request.get(domain, 0)
            wait = self.delay - (now - last)
            if wait > 0:
                await asyncio.sleep(wait)

            self.last_request[domain] = time.time()

            async with httpx.AsyncClient() as client:
                response = await client.get(
                    url,
                    headers={"User-Agent": "PoliteBot/1.0"}
                )
                response.raise_for_status()
                return response

    async def scrape_pages(self, urls):
        results = []
        for url in urls:
            from urllib.parse import urlparse
            domain = urlparse(url).netloc
            result = await self.fetch(url, domain)
            results.append(result)
        return results

Robots.txt Compliance

from urllib.robotparser import RobotFileParser
import httpx

def check_allowed(url, user_agent="PoliteBot/1.0"):
    from urllib.parse import urlparse
    parsed = urlparse(url)
    robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt"

    rp = RobotFileParser()
    rp.set_url(robots_url)
    rp.read()

    return rp.can_fetch(user_agent, url)

# Test
url = "https://example.com/some-page"
if check_allowed(url):
    print("Allowed to scrape")
else:
    print("Blocked by robots.txt")

Practice Questions

  1. Scrape product listings from an e-commerce site (title, price, rating, URL).

  2. Build a recursive scraper that follows all internal links up to depth 3.

  3. Extract tabular data from HTML tables and convert to CSV.

  4. Scrape JavaScript-rendered charts using Playwright (extract data attributes).

  5. Implement a polite scraper that respects robots.txt and rate limits.

Challenge: Website Change Monitor

Build a scraper that:

  • Crawls a list of URLs daily
  • Computes a hash of each page's content
  • Detects and reports changes (diff comparison)
  • Handles dynamic content (wait for JS render)
  • Sends alerts when changes are detected
  • Respects rate limits and robots.txt

This is similar to DodaTech's website monitoring feature, which detects unauthorized changes that could indicate compromise.

Real-World Task: Security Reconnaissance Scraper

Build a scraper that performs basic security reconnaissance:

  • Discovers all subdomains (via certificate transparency logs)
  • Crawls each subdomain for page titles, forms, and JavaScript files
  • Extracts all external links and CDN resources
  • Detects outdated library versions from HTML comments/metadata
  • Reports open admin panels and debug endpoints
  • Generates a report of findings

This is the foundation of DodaTech's attack surface discovery — identifying all publicly accessible assets and their characteristics.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro