Migrate from Scrapy to Crawlee Python Without Changing Your Data Contract

Run equivalent Scrapy and Crawlee Python product crawlers, compare outputs by business key, preserve normalization rules, and define a reversible cutover.

16Yun Engineering TeamOct 28, 20266 min read

The risky part of a crawler migration is not replacing parse() with another callback. It is allowing a working replacement to change the data quietly: amounts become display strings, URLs replace product identifiers, or failed pages are interpreted as removed products.

This tutorial runs Scrapy and Crawlee Python against the first two catalogue pages of Books to Scrape, exports matching fields, and reconciles by UPC. The replacement writes isolated files while the old implementation remains available. Cut over only after scope, fields, and failure handling satisfy the agreed contract.

This is a static HTML migration without a browser. Readers familiar with Scrapy should focus on component mapping, shared normalization, and the final comparison.

Prepare the input addresses

Locate the following pages in your browser, confirm their contents, and copy their full addresses. Obtain service and repository addresses from account integration settings, official setup instructions, or the repository clone menu. Do not derive real addresses from illustrative output.

  • BOOKS_PAGE_1_URL: the Books to Scrape catalogue page-1.html address.
  • BOOKS_PAGE_2_URL: the page-2.html address in the same catalogue.

Set these in the same Bash/WSL terminal used for subsequent commands:

export BOOKS_PAGE_1_URL='<paste-the-actual-books-page-1-url>'
export BOOKS_PAGE_2_URL='<paste-the-actual-books-page-2-url>'

Replace each <VARIABLE_NAME> in JSON or task parameters with the copied value before submission; JSON does not expand environment variables. Any example.16yun.cn source in an output illustration shows field structure, not a demo service deployed for this article.

Freeze the migration contract first

ItemContract
Input scopecatalogue/page-1.html and page-2.html, plus their directly linked product details
Business keyUPC from the detail table, retained as a string
TitleComplete detail-page h1, not a truncated listing title
PriceInteger GBP pence in price_minor; retain price_display for inspection
SourceDetail-page URL as evidence, not as a replacement for UPC
FailuresRetain errors or logs; missing products do not mean removal
Production dataNeither shadow run modifies the production database

The practice site's current structure has 20 products per page, giving a 40-product baseline. In a real migration, derive scope and expected counts from frozen inputs. Reconfirm them when the site changes rather than treating 40 as a permanent truth.

Install both implementations

Use Python 3.12 in a new directory:

python -m venv .venv
source .venv/bin/activate
python -m pip install "Scrapy==2.13.4" "crawlee[parsel]==1.11.1"

Activate with .venv\Scripts\Activate.ps1 in Windows PowerShell. The following sections provide three implementation files and one comparison file in full; no additional project template is needed.

Extract the existing business rules

Save as contract.py:

import os
from decimal import Decimal
 
START_URLS = [
    os.environ["BOOKS_PAGE_1_URL"],
    os.environ["BOOKS_PAGE_2_URL"],
]
 
 
def normalize(selector, source_url):
    title = selector.css(".product_main h1::text").get()
    price = selector.css(".product_main .price_color::text").get()
    upc = selector.xpath(
        "//table//tr[th[normalize-space()='UPC']]/td/text()"
    ).get()
    if not title or not price or not upc:
        raise ValueError("missing_required_product_field")
    title, price, upc = title.strip(), price.strip(), upc.strip()
    if not title or not upc or not price.startswith("£"):
        raise ValueError("invalid_product_field")
    cents = Decimal(price[1:]) * 100
    if not cents.is_finite() or cents < 0 or cents != cents.to_integral_value():
        raise ValueError("invalid_gbp_amount")
    return {
        "upc": upc,
        "title": title,
        "price_minor": int(cents),
        "currency": "GBP",
        "price_display": price,
        "source_url": source_url,
    }

A Scrapy Response and a Crawlee Parsel selector both expose the css() and xpath() operations used here. This permits a shared normalization function. In your project, extract existing Pipeline rules while preserving exceptions, units, null handling, and key semantics. Avoid redefining fields during the migration.

Sharing a function also shares its possible bugs. Reconciliation can reveal scope and execution differences, but cannot replace checking UPC, currency units, and prices against a real page.

Keep an independently runnable Scrapy baseline

Save as old_spider.py:

import scrapy
 
from contract import START_URLS, normalize
 
 
class ProductsSpider(scrapy.Spider):
    name = "migration_baseline"
    allowed_domains = ["books.toscrape.com"]
    start_urls = START_URLS
    custom_settings = {
        "CONCURRENT_REQUESTS": 2,
        "DOWNLOAD_DELAY": 0.5,
        "RETRY_TIMES": 2,
        "FEED_EXPORT_ENCODING": "utf-8",
    }
 
    def parse(self, response):
        links = response.css("article.product_pod h3 a::attr(href)").getall()
        if not links:
            raise ValueError("list_has_no_product_links")
        for href in links:
            yield response.follow(href, callback=self.parse_detail)
 
    def parse_detail(self, response):
        yield normalize(response, response.url)

Run:

scrapy runspider old_spider.py -O old-products.jsonl 2> old-run.log

-O overwrites this exercise output, so archive previous batches first. Inspect the log for exceptions and exhausted retries. A closed spider or finished command does not establish complete coverage.

This baseline illustrates the contract rather than requiring you to rewrite an existing spider. For a real migration, preserve your current implementation, freeze the same inputs, and redirect writes away from the production database.

Rebuild list and detail processing with Crawlee routes

Save as new_crawler.py:

import asyncio
import json
from pathlib import Path
from urllib.parse import urljoin
 
from crawlee import ConcurrencySettings
from crawlee.crawlers import BasicCrawlingContext, ParselCrawler, ParselCrawlingContext
 
from contract import START_URLS, normalize
 
 
def save_jsonl(path, rows):
    Path(path).write_text(
        "".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows),
        encoding="utf-8",
    )
 
 
async def main():
    products = {}
    failures = []
    list_urls = set()
    discovered = set()
    crawler = ParselCrawler(
        max_requests_per_crawl=60,
        max_request_retries=2,
        concurrency_settings=ConcurrencySettings(
            min_concurrency=1, max_concurrency=2
        ),
    )
 
    @crawler.router.default_handler
    async def listing(context: ParselCrawlingContext):
        if context.request.url not in START_URLS:
            raise ValueError("unexpected_list_url")
        links = context.selector.css(
            "article.product_pod h3 a::attr(href)"
        ).getall()
        if not links:
            raise ValueError("list_has_no_product_links")
        discovered.update(urljoin(context.request.url, href) for href in links)
        await context.enqueue_links(
            selector="article.product_pod h3 a", label="DETAIL"
        )
        list_urls.add(context.request.url)
 
    @crawler.router.handler("DETAIL")
    async def detail(context: ParselCrawlingContext):
        record = normalize(context.selector, context.request.url)
        previous = products.get(record["upc"])
        if previous is not None and previous != record:
            raise ValueError("conflicting_duplicate_upc")
        products[record["upc"]] = record
 
    @crawler.failed_request_handler
    async def failed(context: BasicCrawlingContext, error: Exception):
        failures.append({
            "url": context.request.url,
            "label": context.request.label,
            "error_type": type(error).__name__,
            "error": str(error),
        })
 
    try:
        await crawler.run(START_URLS)
    finally:
        save_jsonl("new-products.jsonl", [
            products[key] for key in sorted(products)
        ])
        Path("new-errors.json").write_text(
            json.dumps(failures, ensure_ascii=False, indent=2),
            encoding="utf-8",
        )
 
    actual = {row["source_url"] for row in products.values()}
    if failures or list_urls != set(START_URLS) or actual != discovered:
        raise RuntimeError("incomplete_migration_batch_check_outputs")
 
 
if __name__ == "__main__":
    asyncio.run(main())

Run with an independent Crawlee storage directory:

CRAWLEE_STORAGE_DIR=./storage-migration-01 python new_crawler.py

In PowerShell, set $env:CRAWLEE_STORAGE_DIR="./storage-migration-01" before running Python. Use a fresh storage directory for each independent comparison so previously handled requests cannot affect the new batch. Recovery testing is a separate scenario and should not be mixed into a first-run migration comparison.

This example collects 40 records in a dictionary and exports them at the end. For large production runs, retain transactional writes and idempotent UPC updates instead of expanding this bounded sample into an in-memory full-site crawl.

Map behavior, not merely component names

Scrapy componentReplacement hereDifference to verify
parse / parse_detailDefault list route / DETAIL routeLabels survive enqueueing
response.followenqueue_linksScope and URL deduplication agree
Item Pipelinenormalize plus result writingInvalid data is retried, quarantined, or stops processing as intended
Feed exportExplicit JSONL exportEncoding, overwrite behavior, field types
Downloader middlewareReconfigure the required capabilityProxy, cookies, and redirects are not copied as middleware classes
JOBDIR / request stateCrawlee storageScrapy queue files cannot be reused

Both examples allow two retries, but that does not make every failure equivalent. Scrapy download retries and callback exceptions differ from Crawlee request-handler retries. Verify the failure scenarios that matter to your application rather than comparing only configuration values.

Compare both outputs by business key

Save as compare.py:

import json
from pathlib import Path
 
 
def load(path):
    rows = [
        json.loads(line)
        for line in Path(path).read_text(encoding="utf-8").splitlines()
        if line.strip()
    ]
    result = {}
    for row in rows:
        key = row["upc"]
        if key in result:
            raise ValueError("duplicate_upc_in_" + path + ":" + key)
        if type(row["price_minor"]) is not int or row["currency"] != "GBP":
            raise ValueError("schema_mismatch_in_" + path)
        result[key] = row
    return result
 
 
old = load("old-products.jsonl")
new = load("new-products.jsonl")
missing = sorted(old.keys() - new.keys())
extra = sorted(new.keys() - old.keys())
changed = {
    key: {
        field: {"old": old[key].get(field), "new": new[key].get(field)}
        for field in sorted(old[key].keys() | new[key].keys())
        if old[key].get(field) != new[key].get(field)
    }
    for key in sorted(old.keys() & new.keys())
    if old[key] != new[key]
}
report = {
    "old_count": len(old), "new_count": len(new),
    "missing": missing, "extra": extra, "changed": changed,
}
Path("comparison.json").write_text(
    json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(json.dumps(report, ensure_ascii=False, indent=2))
if len(old) != 40 or len(new) != 40 or missing or extra or changed:
    raise SystemExit("migration_comparison_failed")

Run python compare.py. If the site has not changed and both implementations cover the entire agreed scope, the acceptance target is two counts of 40 with empty missing, extra, and changed values. This is a condition to check, not a promised execution result.

Then open at least one product and verify its complete title, UPC, GBP unit, and price. Inspect old-run.log and new-errors.json as well. Identical output may still contain a shared normalization bug.

Investigate differences before changing the contract

DifferenceLikely causeNext step
Fewer new recordsMissing list processing, wrong labels, reused storage, detail failuresCompare discovered and actual URLs, inspect errors and the batch directory
Both counts below 40Changed input scope or page structure, shared selector bugCheck actual links on both pages; matching shortfalls do not pass
Different titles for one UPCListing titles versus complete detail titlesStandardize on detail h1 and rerun both
Prices differ by 100Major and minor units mixed, implicit string conversionUse integer pence and inspect one product manually
Only source_url differsRedirect, protocol, or normalization differencesDefine source-field semantics before accepting the difference
Duplicate UPCSeveral URLs for one product, or an incorrectly selected fixed identifierInspect details; do not hide conflicting content by overwriting a dictionary

Cut over gradually and retain a real rollback path

First use a fixed scope and separate outputs. After checking the contract, coverage, errors, and a manual sample, let the replacement process a small production batch. Keep the old task entry point, configuration, and last working version.

Continue using existing business keys and transaction rules. Do not empty historical tables before importing the replacement output, or convert one missing observation into deletion. If fields change, coverage drops, or failures rise, pause the new schedule and restore the old entry point. Locate already written records by batch. A rollback must restore scheduling and traceable data versions; deleting a new working directory does not undo database writes.

Expand the migration to other sites, authentication, proxies, dynamic pages, and persistence one scenario at a time, each with its own comparison and rollback conditions. Completion means preserving the business behavior, not merely replacing a dependency.

See the Scrapy tutorial, Scrapy feed exports, and Crawlee Python quick start.

Need an enterprise proxy plan?

We can tailor architecture to your target domains, concurrency, and reliability goals.