Scraping Using the Scrapy Framework in Python: Complete Web Crawling and Data Extraction Guide

Scraping using the Scrapy framework in python

Scraping using the Scrapy framework in python

There’s a point in almost every scraping project where a simple requests + BeautifulSoup script stops being enough — I need to crawl thousands of pages, follow links automatically, respect crawl delays, retry failed requests, and export structured data reliably. That’s exactly the point where I switch to Scrapy. It’s a full application framework for web crawling, not just a parsing library, and understanding its architecture properly made an enormous difference in how effectively I use it. This guide covers Scrapy from the ground up, including the internal request/response cycle that explains why it’s so much faster than sequential scraping approaches.

Why Scrapy Is Different from requests + BeautifulSoup

requests and BeautifulSoup are libraries — I write the entire control flow myself: fetch a page, parse it, decide what to fetch next, loop. Scrapy is a framework — it provides the control flow, and I fill in the specific logic (what to extract, which links to follow) within its structure. The biggest practical difference I noticed immediately is concurrency: Scrapy is built on an asynchronous networking engine (Twisted), meaning it can have dozens of requests in flight simultaneously without me writing any async code myself, whereas a naive requests-based loop processes one page at a time, sequentially, waiting for each response before starting the next.

Installing Scrapy and Creating a Project

pip install scrapy --break-system-packages
scrapy startproject bookscraper
cd bookscraper

This generates a structured project directory:

bookscraper/
    scrapy.cfg
    bookscraper/
        __init__.py
        items.py
        middlewares.py
        pipelines.py
        settings.py
        spiders/
            __init__.py

Writing Your First Spider

A “spider” is Scrapy’s term for the class that defines what to scrape and how. I generate one with a command, or write it directly:

# bookscraper/spiders/books_spider.py
import scrapy

class BooksSpider(scrapy.Spider):
    name = "books"
    start_urls = ["https://books.toscrape.com/"]

    def parse(self, response):
        for book in response.css("article.product_pod"):
            yield {
                "title": book.css("h3 a::attr(title)").get(),
                "price": book.css("p.price_color::text").get(),
                "availability": book.css("p.instock.availability::text").get(default="").strip(),
            }

        next_page = response.css("li.next a::attr(href)").get()
        if next_page is not None:
            yield response.follow(next_page, callback=self.parse)

Running it:

scrapy crawl books -o books.json

This single spider will crawl every paginated page on the site automatically, extract structured data from each book listing, and write the results to books.json — following next_page links until there are none left.

Understanding response.css() and response.xpath()

Scrapy gives me two selector systems, and I mix both depending on what’s easiest for a given element.

# CSS selectors
title = response.css("h1::text").get()
all_links = response.css("a::attr(href)").getall()

# XPath selectors - more powerful for text-based or structural matching
title = response.xpath("//h1/text()").get()
price = response.xpath("//span[@class='price']/text()").get()

.get() returns the first match as a string (or None if nothing matched), while .getall() returns every match as a list. I default to .get() when I expect exactly one result (like a page title) and .getall() when I expect several (like every link on a page).

The Item Pipeline: Structuring and Cleaning Extracted Data

Rather than yielding raw dicts everywhere, I define Item classes for anything beyond a quick throwaway spider — this gives me a defined schema and catches typos in field names early.

# bookscraper/items.py
import scrapy

class BookItem(scrapy.Item):
    title = scrapy.Field()
    price = scrapy.Field()
    availability = scrapy.Field()
# bookscraper/spiders/books_spider.py
import scrapy
from bookscraper.items import BookItem

class BooksSpider(scrapy.Spider):
    name = "books"
    start_urls = ["https://books.toscrape.com/"]

    def parse(self, response):
        for book in response.css("article.product_pod"):
            item = BookItem()
            item["title"] = book.css("h3 a::attr(title)").get()
            item["price"] = book.css("p.price_color::text").get()
            item["availability"] = book.css("p.instock.availability::text").get(default="").strip()
            yield item

Then, in pipelines.py, I define processing steps that every scraped item passes through — cleaning, validating, or storing data.

# bookscraper/pipelines.py
class PriceCleaningPipeline:
    def process_item(self, item, spider):
        if item.get("price"):
            # convert "£10.99" into a clean float
            item["price"] = float(item["price"].replace("£", "").strip())
        return item

class DuplicatesPipeline:
    def __init__(self):
        self.seen_titles = set()

    def process_item(self, item, spider):
        if item["title"] in self.seen_titles:
            raise scrapy.exceptions.DropItem(f"Duplicate item found: {item['title']}")
        self.seen_titles.add(item["title"])
        return item

I enable these in settings.py:

# bookscraper/settings.py
ITEM_PIPELINES = {
    "bookscraper.pipelines.PriceCleaningPipeline": 300,
    "bookscraper.pipelines.DuplicatesPipeline": 400,
}

The numbers control execution order (lower runs first) — I think of the pipeline as an assembly line, where each stage cleans or validates the item a bit further before it reaches storage.

Internal Working: The Scrapy Engine and Asynchronous Request Handling

This is the part that genuinely changed how I think about scraping performance. Scrapy is built on Twisted, an event-driven asynchronous networking framework. Instead of a single thread blocking on each HTTP request one at a time, Scrapy’s engine maintains a queue of pending requests and dispatches many of them concurrently, processing responses as they arrive, in whatever order they happen to complete — not necessarily the order they were sent.

The core cycle looks roughly like this: the Scheduler holds a queue of pending requests; the Downloader fetches pages, handling many requests in flight simultaneously via Twisted’s non-blocking I/O; as each response comes back, it’s routed to the spider’s parse() method (or whichever callback was specified), which can yield both extracted items and new requests (like the next_page follow-link) in the same generator function; those new requests go back into the Scheduler’s queue, and the cycle continues until there’s nothing left to process.

This is fundamentally why Scrapy dramatically outperforms a naive sequential requests loop for crawling many pages — while waiting for one page’s network response, the engine is simultaneously sending and processing other requests, rather than sitting idle. I control the degree of concurrency directly:

# settings.py
CONCURRENT_REQUESTS = 16          # max simultaneous requests overall
CONCURRENT_REQUESTS_PER_DOMAIN = 8  # max simultaneous requests to a single domain
DOWNLOAD_DELAY = 0.5              # polite delay between requests to the same domain

Respecting robots.txt and Being a Good Citizen

Scrapy checks robots.txt by default, and I keep this enabled unless I have a specific, justified reason not to.

# settings.py
ROBOTSTXT_OBEY = True

I also set a descriptive USER_AGENT and a reasonable DOWNLOAD_DELAY, since hammering a site with maximum concurrency and no delay is exactly the kind of behavior that gets scrapers IP-banned and, more importantly, degrades service for a site’s legitimate users.

USER_AGENT = "bookscraper (+https://mywebsite.example.com/contact)"
DOWNLOAD_DELAY = 1
AUTOTHROTTLE_ENABLED = True  # dynamically adjusts delay based on server response times

AUTOTHROTTLE_ENABLED is a setting I turn on for almost every real crawl — it automatically slows down when a server starts responding slowly (a sign it’s under load) and speeds back up when the server is responding quickly, which is far more considerate than a fixed delay that’s either unnecessarily slow on a fast server or too aggressive on a struggling one.

Handling Pagination and Following Links

The response.follow() method is one of Scrapy’s most convenient features — it automatically resolves relative URLs against the current page’s URL, which I’d otherwise have to handle manually with urljoin().

def parse(self, response):
    for link in response.css("a.category-link::attr(href)"):
        yield response.follow(link, callback=self.parse_category)

def parse_category(self, response):
    for product in response.css(".product"):
        yield {"name": product.css("h2::text").get()}

Exporting Data in Different Formats

scrapy crawl books -o books.json
scrapy crawl books -o books.csv
scrapy crawl books -o books.xml
scrapy crawl books -o books.jsonl   # JSON Lines, one item per line

Scrapy’s built-in feed exporters handle all the formatting, escaping, and structuring automatically based purely on the output file’s extension — I don’t write any export code myself for the common formats.

Handling Errors and Retries

# settings.py
RETRY_ENABLED = True
RETRY_TIMES = 3
RETRY_HTTP_CODES = [500, 502, 503, 504, 408, 429]

Scrapy automatically retries requests that fail with these status codes, using an exponential-ish backoff strategy internally, which has saved me from writing manual retry logic for the vast majority of transient failures.

For errors within my own parsing logic, I define an errback to handle failed requests gracefully rather than letting one bad page silently kill part of the crawl:

import scrapy

class RobustSpider(scrapy.Spider):
    name = "robust"
    start_urls = ["https://example.com/"]

    def start_requests(self):
        for url in self.start_urls:
            yield scrapy.Request(url, callback=self.parse, errback=self.handle_error)

    def parse(self, response):
        yield {"title": response.css("title::text").get()}

    def handle_error(self, failure):
        self.logger.error(f"Request failed: {failure.request.url}")

Using the Scrapy Shell for Rapid Development

Before writing a full spider, I almost always use the interactive shell to test my selectors against a real page first.

scrapy shell "https://books.toscrape.com/"
>>> response.css("h1::text").get()
>>> response.css("article.product_pod")[0].css("h3 a::attr(title)").get()

This iterative testing loop — try a selector, see the result immediately, adjust — saves me enormous amounts of time compared to running the full spider repeatedly just to check whether one CSS selector is correct.

Common Mistakes I’ve Made

Real-World Use Cases

  1. Large-scale product data collection across e-commerce category pages and pagination.
  2. News and content aggregation, crawling article listings and following links to full articles.
  3. Price monitoring pipelines that run on a schedule, tracking changes over time.
  4. Structured data extraction for research, feeding into downstream analysis pipelines.

FAQs

Is Scrapy faster than a requests + BeautifulSoup loop? For crawling many pages, yes, significantly — Scrapy’s asynchronous engine handles many concurrent requests without the sequential blocking a plain requests loop incurs.

Does Scrapy handle JavaScript-rendered pages? Not natively — Scrapy fetches raw HTML like requests does. For JavaScript-heavy sites, it can be combined with scrapy-splash or scrapy-playwright middleware, or you’d use Selenium instead for those specific pages.

How do I avoid getting blocked while scraping with Scrapy? Enable AUTOTHROTTLE_ENABLED, set a reasonable DOWNLOAD_DELAY, use a descriptive USER_AGENT, respect robots.txt, and avoid unnecessarily high concurrency settings.

What’s the difference between response.css() and response.xpath()? Both select elements from the page; CSS selectors are often more concise and familiar, while XPath supports more powerful matching, including text-content-based searches that CSS alone can’t express.

Can I run multiple spiders at once? Yes, using scrapy crawl spider1 & scrapy crawl spider2 from the command line, or programmatically via CrawlerProcess for more control within a single script.

Summary

Scrapy earns its place as a full framework rather than a simple library specifically because large-scale crawling needs concurrency, retry logic, pipeline processing, and export handling that would otherwise mean writing and maintaining a lot of infrastructure code myself. Understanding its asynchronous request/response cycle, built on Twisted, explains both why it’s so much faster than sequential scraping and why respecting concurrency and delay settings matters — the same architecture that makes it fast also makes it capable of overwhelming a target server if used carelessly. Once I understood the engine, spider, and pipeline architecture together, Scrapy stopped feeling like a black box and became the tool I reach for by default whenever a scraping task grows beyond a handful of pages.

References

Exit mobile version