DEV Community

Cover image for Scrapling: The Python Scraper That Doesn't Break When Sites Change (2026)
ZyVOP
ZyVOP

Posted on • Originally published at zyvop.com

Scrapling: The Python Scraper That Doesn't Break When Sites Change (2026)

The Problem Every Python Scraper Has

You spend a Tuesday afternoon writing a scraper. CSS selectors, BeautifulSoup, a loop, a CSV export. It works perfectly.

You come back three weeks later. The data is missing. The site redesigned. The class .product-price is now .price-display. Your soup.select_one(".product-price") returns None across all 50,000 URLs you were monitoring.

This is not an edge case. It is the normal lifecycle of every production scraper. D4Vinci/Scrapling — Python web scraper that adapts when sites change, so your data collection keeps working.

Scrapling is the answer to this problem. Instead of finding elements by their exact CSS class — which breaks the moment a developer renames it — Scrapling finds elements by their structural position and visual role in the document. The class name is a hint, not the whole answer. When the hint changes, Scrapling adapts.

By GitHub stars: Firecrawl (131k+), Playwright (90.2k+), Crawl4AI (68.2k+), Scrapy (62.1k+). Scrapling has been growing fast precisely because it solves the problem that every scraper maintainer hates most.

This guide shows you the whole library — the parsing layer (Adaptor), the killer adaptive features (find_similar, auto-generated selectors), and how Scrapling's fetchers handle real websites.


Installation

\n\n

bash\npip install scrapling curl_cffi
\n


\n\n

curl_cffi is required by Scrapling's fetchers for TLS fingerprint impersonation. Install both together.


Part 1: The Adaptor — Scrapling's HTML Parser

The Adaptor is Scrapling's HTML parsing layer. Think of it as BeautifulSoup with a smarter element model and additional methods for adaptive scraping.

\n\n

```python\nfrom scrapling.parser import Adaptor

html = """


Senior Python Developer


Infosys
Bangalore
Rs. 18–25 LPA
Python
Django
Apply Now


Data Scientist


TCS
Hyderabad
Rs. 15–22 LPA
Python
ML
Apply Now


"""

Create an Adaptor — pass the HTML and the source URL

The URL is used for resolving relative links and for the auto-trained model

page = Adaptor(html, url="https://techjobs.example.com")
\n```

\n\n

Selecting elements

Scrapling supports CSS selectors and XPath:

\n\n

```python\n# CSS selectors — returns a Selectors list
titles = page.css("h3.job-title")
print([t.text for t in titles])

['Senior Python Developer', 'Data Scientist']

XPath — same return type

titles_xpath = page.xpath("//h3[@class='job-title']")
print([t.text for t in titles_xpath])

['Senior Python Developer', 'Data Scientist']

find_all by tag — like BeautifulSoup's find_all

items = page.find_all("span")
print(f"Found {len(items)} spans")
\n```

\n\n

Getting values out

\n\n

```python\n# .text — the element's visible text content
title_el = page.css("h3.job-title")[0]
print(title_el.text) # Senior Python Developer

.html — the element's outer HTML

print(title_el.html) #

Senior Python Developer

.attrib — dict-like attribute access (never raises KeyError)

card = page.css("div.job-card")[0]
print(card.attrib["data-id"]) # 101
print(card.attrib.get("data-missing")) # None — safe access

Link href

link = page.css("a.apply-btn")[0]
print(link.attrib["href"]) # /jobs/101

All text in a container (concatenates all descendant text nodes)

card_text = card.get_all_text(separator=" ")
print(card_text[:60]) # Senior Python Developer Infosys Bangalore Rs. 18...
\n```

\n\n

extract() and extract_first()

These return the raw HTML string of matched elements — useful when you need to re-parse a sub-section:

\n\n

```python\n# extract_first() → the first match as an HTML string
first_title_html = page.css("h3.job-title").extract_first()
print(first_title_html) #

Senior Python Developer

extract() → list of all matches as HTML strings

all_price_html = page.css("span.salary").extract()
print(all_price_html)

['Rs. 18–25 LPA',

'Rs. 15–22 LPA']

\n```

\n\n

Nested selection

You can run CSS selectors on individual elements, not just the whole page:

\n\n

```python\njobs = []
for card in page.css("div.job-card"):
job = {
"id": card.attrib.get("data-id"),
"title": card.css("h3.job-title")[0].text,
"company": card.css("span.company")[0].text,
"location": card.css("span.location")[0].text,
"salary": card.css("span.salary")[0].text,
"tags": [t.text for t in card.css("span.tag")],
"link": card.css("a.apply-btn")[0].attrib["href"],
}
jobs.append(job)

for j in jobs:
print(f" [{j['id']}] {j['title']} @ {j['company']} — {j['salary']}")
print(f" {', '.join(j['tags'])}")
\n```

\n\n

Traversal

\n\n

```python\n# children — iterate direct child elements
ul_html = "

  • Item A
  • Item B
  • Item C
"
page2 = Adaptor(ul_html, url="https://example.com")
ul = page2.css("ul")[0]

for child in ul.children:
if hasattr(child, "text"):
print(child.text) # Item A, Item B, Item C

find_ancestor — walk up the tree with a test function

html3 = "

Alice
"
page3 = Adaptor(html3, url="https://example.com")
td = page3.css("td.name")[0]

tr = td.find_ancestor(lambda el: el.tag == "tr")
print(tr.tag) # tr
\n```

\n\n


Part 2: The Features That Make Scrapling Different

find_similar() — the adaptive core

This is the feature that makes Scrapling special. find_similar() finds all elements on the page that are structurally similar to a given element — same depth, same relationship to siblings and parent, same content type — even if their CSS class has changed.

\n\n

```python\nhtml = """

Alice 30
Bob 25
Carol 35

"""
page = Adaptor(html, url="https://example.com")

Get only the FIRST name cell by selector

first_name = page.css("td.name")[0]
print("First:", first_name.text) # Alice

find_similar() finds the other name-like cells by structural position

— NOT by class name. If "td.name" becomes "td.member-name" tomorrow,

this still works.

similar = first_name.find_similar()
print("Similar:", [s.text for s in similar]) # ['Bob', 'Carol']
\n```

\n\n

The practical workflow this enables: identify the first example of the element you want, then call find_similar() to get all others. If the site redesigns and changes class names, find_similar() still finds them by structural position.

find_by_text() — locate elements by content

\n\n

```python\nhtml = """

Product Overview

A quality product designed for professionals.

Buy Now

"""
page = Adaptor(html, url="https://example.com")

Find an element by its exact text

btn = page.find_by_text("Buy Now")
print(btn.text) # Buy Now
print(btn.tag) # button

Find by partial text

desc = page.find_by_text("quality product", partial=True)
print(desc.text) # A quality product designed for professionals.
\n```

\n\n

Auto-generated selectors — never write a selector manually again

Scrapling can generate a CSS selector or XPath expression for any element it finds. Use this to discover selectors rather than hunting through DevTools:

\n\n

```python\nhtml = """

"""
page = Adaptor(html, url="https://example.com")

pricing_link = page.find_by_text("Pricing")
print("CSS selector:", pricing_link.generate_css_selector)

body > nav > ul > li:nth-child(2) > a

print("XPath:", pricing_link.generate_xpath_selector)

//body/nav/ul/li[2]/a

print("Full CSS:", pricing_link.generate_full_css_selector)

html > body > nav > ul > li:nth-child(2) > a

\n```

\n\n

Use case: you're scraping a new site and aren't sure what selectors to use. Run find_by_text() on known content, then call generate_css_selector to get the precise selector to use in your production code.


Part 3: Fetching Pages with Scrapling

The Adaptor alone is a parser — you bring the HTML. Scrapling also ships three fetcher classes that handle the HTTP layer and return Adaptor objects directly.

Fetcher — fast HTTP, no browser

Fetcher uses httpx under the hood. Good for simple server-rendered pages with no anti-bot protection.

\n\n

```python\nfrom scrapling.fetchers import Fetcher

fetcher = Fetcher(auto_match=True) # auto_match enables adaptive matching

fetch() returns an Adaptor object — parse it immediately

page = fetcher.get(
"https://books.toscrape.com/",
timeout=20,
stealthy_headers=True, # Add realistic browser headers automatically
)

Use Adaptor methods on the result

titles = page.css("h3 a")
for t in titles[:5]:
print(t.attrib.get("title", t.text))
\n```

\n\n

StealthyFetcher — TLS impersonation

StealthyFetcher uses curl_cffi to impersonate real browser TLS fingerprints. Use this for sites that check JA3/TLS fingerprints (Cloudflare, most major e-commerce sites).

\n\n

```python\nfrom scrapling.fetchers import StealthyFetcher

fetcher = StealthyFetcher(auto_match=True)

page = fetcher.get(
"https://target-site.com/products",
timeout=30,
# Scrapling picks a Chrome impersonation target automatically
)

Result is still an Adaptor — same API

prices = page.css(".price")
print([p.text for p in prices])
\n```

\n\n

PlayWrightFetcher — full browser rendering

For JavaScript-heavy sites:

\n\n

```python\nfrom scrapling.fetchers import PlayWrightFetcher

fetcher = PlayWrightFetcher(auto_match=True)

page = fetcher.get(
"https://spa-site.com/dashboard",
headless=True,
network_idle=True, # Wait for all XHR to complete
timeout=30,
disable_resources=True, # Block images/fonts for speed
)

Same Adaptor API

data = page.css(".data-table tr")
\n```

\n\n


Part 4: The auto_match System

auto_match=True is Scrapling's most powerful feature — and the one that requires a bit of explanation.

When you use auto_match, Scrapling stores a fingerprint of each element you extract — not just its CSS class, but its position in the DOM tree, its relationship to sibling elements, its text content pattern, and other structural signals. On subsequent runs, if the CSS class has changed, Scrapling uses this fingerprint to locate the element anyway.

\n\n

```python\nfrom scrapling.fetchers import Fetcher

fetcher = Fetcher(auto_match=True)

First run: Scrapling fetches the page and learns element fingerprints

page = fetcher.get("https://example.com/products")

You select by CSS class — Scrapling memorises the element

price = page.css_first(".product-price")
print(price.text) # Rs. 1,299

Three weeks later: site redesigns, ".product-price" → ".price-tag"

Scrapling's auto_match finds the element by structural fingerprint

Even though the CSS class changed, it still returns the right element

page2 = fetcher.get("https://example.com/products")
price2 = page2.css_first(".product-price") # Old selector still works!
print(price2.text) # Rs. 1,299 — correct even after the redesign
\n```

\n\n

This is the real promise of Scrapling: selectors that survive redesigns.


Part 5: A Complete Production Scraper with Scrapling

Let's build a complete working scraper that uses Scrapling's full feature set:

\n\n

```python\n# scrapling_full_example.py
import asyncio
import pandas as pd
from scrapling.parser import Adaptor

We use Adaptor with pre-fetched HTML for this example

In production, replace with StealthyFetcher.get() or PlayWrightFetcher.get()

SAMPLE_PAGE_HTML = """<!DOCTYPE html>

Tech Products — Store

Latest Products


<article class="product-card" data-sku="WGT-001">
  <h2 class="product-name">Wireless Keyboard Pro</h2>
  <div class="pricing">
    <span class="current-price">Rs. 3,499</span>
    <span class="original-price">Rs. 4,999</span>
    <span class="discount-badge">30% off</span>
  </div>
  <ul class="feature-list">
    <li>Bluetooth 5.2</li>
    <li>Multi-device pairing</li>
    <li>12-month battery</li>
  </ul>
  <div class="meta">
    <span class="brand">Logitech</span>
    <span class="rating">4.6 ★</span>
    <span class="reviews">2,841 reviews</span>
  </div>
  <a href="/products/WGT-001" class="product-link">View Product</a>
</article>

<article class="product-card" data-sku="MNT-002">
  <h2 class="product-name">4K USB-C Monitor</h2>
  <div class="pricing">
    <span class="current-price">Rs. 28,999</span>
    <span class="original-price">Rs. 34,999</span>
    <span class="discount-badge">17% off</span>
  </div>
  <ul class="feature-list">
    <li>4K 27-inch IPS</li>
    <li>USB-C 90W PD</li>
    <li>HDMI 2.1</li>
  </ul>
  <div class="meta">
    <span class="brand">LG</span>
    <span class="rating">4.8 ★</span>
    <span class="reviews">1,204 reviews</span>
  </div>
  <a href="/products/MNT-002" class="product-link">View Product</a>
</article>

<article class="product-card" data-sku="HDS-003">
  <h2 class="product-name">ANC Headphones Elite</h2>
  <div class="pricing">
    <span class="current-price">Rs. 12,499</span>
    <span class="original-price">Rs. 15,999</span>
    <span class="discount-badge">22% off</span>
  </div>
  <ul class="feature-list">
    <li>Active Noise Cancellation</li>
    <li>40hr battery</li>
    <li>Hi-Res Audio</li>
  </ul>
  <div class="meta">
    <span class="brand">Sony</span>
    <span class="rating">4.7 ★</span>
    <span class="reviews">5,632 reviews</span>
  </div>
  <a href="/products/HDS-003" class="product-link">View Product</a>
</article>


"""

def scrape_products(html: str, source_url: str) -> list[dict]:
"""
Parse product cards from a page using Scrapling.
Demonstrates: nested css(), .text, .attrib, find_similar(), find_by_text()
"""
page = Adaptor(html, url=source_url)
products = []

for card in page.css("article.product-card"):
    sku        = card.attrib.get("data-sku", "")
    name       = card.css("h2.product-name")[0].text

    # Pricing block
    price_curr = card.css("span.current-price")[0].text
    price_orig = card.css("span.original-price")[0].text
    discount   = card.css("span.discount-badge")[0].text

    # Features — list items
    features = [li.text for li in card.css("ul.feature-list li")]

    # Meta
    brand    = card.css("span.brand")[0].text
    rating   = card.css("span.rating")[0].text
    reviews  = card.css("span.reviews")[0].text

    # Link
    link     = card.css("a.product-link")[0].attrib["href"]

    # Clean price to float
    import re
    price_float = float(re.sub(r"[^\d]", "", price_curr))

    products.append({
        "sku":           sku,
        "name":          name,
        "price":         price_float,
        "price_display": price_curr,
        "original":      price_orig,
        "discount":      discount,
        "features":      ", ".join(features),
        "brand":         brand,
        "rating":        rating,
        "reviews":       reviews,
        "url":           link,
    })

return products
Enter fullscreen mode Exit fullscreen mode

def demonstrate_adaptive_features(html: str):
"""
Show Scrapling's key adaptive features:
find_similar(), find_by_text(), auto-generated selectors
"""
page = Adaptor(html, url="https://store.example.com")

print("\n── find_similar() ──")
# Get the first product name
first_name = page.css("h2.product-name")[0]
print(f"First product: {first_name.text}")

# find_similar finds the other product names by structural position
# This works even after CSS class renames
similar = first_name.find_similar()
print(f"Similar elements found: {[s.text for s in similar]}")

print("\n── find_by_text() ──")
# Locate element by its text content — class-independent
sony_brand = page.find_by_text("Sony")
if sony_brand:
    print(f"Found 'Sony' in: <{sony_brand.tag}> element")
    # Walk up to get the parent product card
    parent_card = sony_brand.find_ancestor(lambda el: el.tag == "article")
    if parent_card:
        product_name = parent_card.css("h2")[0].text
        print(f"  → Parent product: {product_name}")

print("\n── find_by_text() partial match ──")
anc_el = page.find_by_text("Noise Cancellation", partial=True)
if anc_el:
    print(f"Partial match: '{anc_el.text}'")

print("\n── Auto-generated selectors ──")
# Let Scrapling generate the selector for any element
first_discount = page.css("span.discount-badge")[0]
print(f"CSS selector: {first_discount.generate_css_selector}")
print(f"XPath:        {first_discount.generate_xpath_selector}")

print("\n── get_all_text() ──")
first_card = page.css("article.product-card")[0]
all_text   = first_card.get_all_text(separator=" | ")
print(f"All text in first card: {all_text[:120]}...")
Enter fullscreen mode Exit fullscreen mode

def main():
print("=" * 55)
print(" SCRAPLING DEMO")
print("=" * 55)

# Scrape products
products = scrape_products(SAMPLE_PAGE_HTML, "https://store.example.com")

print(f"\nScraped {len(products)} products:\n")
df = pd.DataFrame(products)
print(df[["sku", "name", "price", "discount", "brand", "rating"]].to_string(index=False))

# Analysis
print(f"\nAverage price: Rs. {df['price'].mean():,.0f}")
print(f"Most expensive: {df.loc[df['price'].idxmax(), 'name']} "
      f"(Rs. {df['price'].max():,.0f})")
print(f"Cheapest: {df.loc[df['price'].idxmin(), 'name']} "
      f"(Rs. {df['price'].min():,.0f})")

# Demonstrate adaptive features
demonstrate_adaptive_features(SAMPLE_PAGE_HTML)

# Save
df.to_csv("scrapling_products.csv", index=False)
print("\n✓ Data saved to scrapling_products.csv")
Enter fullscreen mode Exit fullscreen mode

if name == "main":
main()
\n```

\n\n

Output:

\n\n

  SCRAPLING DEMO
=======================================================

Scraped 3 products:

     sku                    name     price discount    brand  rating
 WGT-001    Wireless Keyboard Pro    3499.0   30% off Logitech  4.6 ★
 MNT-002          4K USB-C Monitor  28999.0   17% off       LG  4.8 ★
 HDS-003     ANC Headphones Elite   12499.0   22% off     Sony  4.7 ★

Average price: Rs. 14,999
Most expensive: 4K USB-C Monitor (Rs. 28,999)
Cheapest: Wireless Keyboard Pro (Rs. 3,499)

── find_similar() ──
First product: Wireless Keyboard Pro
Similar elements found: ['4K USB-C Monitor', 'ANC Headphones Elite']

── find_by_text() ──
Found 'Sony' in: <span> element
  → Parent product: ANC Headphones Elite

── find_by_text() partial match ──
Partial match: 'Active Noise Cancellation'

── Auto-generated selectors ──
CSS selector: body > div > article > div > span:nth-child(3)
XPath:        //body/div/article/div/span[3]

── get_all_text() ──
All text in first card: Wireless Keyboard Pro | Rs. 3,499 | Rs. 4,999 | 30% off...

✓ Data saved to scrapling_products.csv
\n```

\n\n

* * *

## Part 6: Scrapling vs BeautifulSoup — Honest Comparison

| Feature | BeautifulSoup | Scrapling |
| --- | --- | --- |
| Syntax simplicity | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| CSS selectors | ✅ | ✅ |
| XPath | ❌ (needs lxml directly) | ✅ |
| find_by_text | Basic | Advanced with partial match |
| find_similar | ❌ | ✅ (unique to Scrapling) |
| Auto-selectors | ❌ | ✅ |
| Auto-match on redesign | ❌ | ✅ |
| Built-in fetchers | ❌ | ✅ (3 fetcher types) |
| TLS impersonation | ❌ | ✅ (via StealthyFetcher) |
| Speed | Fast | Slightly slower |
| Documentation | Excellent | Good, growing |
| Community | Enormous | Rapidly growing |

**When to use BeautifulSoup:** You're scraping a stable site, you're a beginner, or you're teaching someone else.

**When to use Scrapling:** You're scraping sites that change frequently, you want built-in fetchers with anti-detection, or you're tired of maintaining selector lists.

* * *

## Part 7: Working with Scrapling's Fetchers in Production

\n\n

```python\n# production_fetcher_example.py
from scrapling.fetchers import StealthyFetcher
import time
import random

def scrape_with_retry(url: str, max_retries: int = 3) -> object | None:
    """
    Production-safe wrapper around StealthyFetcher.
    Handles retries and errors gracefully.
    """
    fetcher = StealthyFetcher(auto_match=True)

    for attempt in range(max_retries):
        try:
            page = fetcher.get(
                url,
                timeout        = 25,
                stealthy_headers=True,    # Scrapling adds realistic headers
                hide_browser   = True,    # Additional stealth for PlayWright
            )
            return page

        except Exception as e:
            wait = (2 ** attempt) + random.uniform(0, 1)
            print(f"  Attempt {attempt+1} failed: {e}. Retrying in {wait:.1f}s")
            time.sleep(wait)

    print(f"  Permanently failed: {url}")
    return None

# Example multi-page scrape
def scrape_catalog(base_url: str, pages: int = 5) -> list[dict]:
    all_products = []

    for page_num in range(1, pages + 1):
        url  = f"{base_url}?page={page_num}"
        page = scrape_with_retry(url)

        if not page:
            continue

        # Adaptive scraping — if class names change between pages,
        # find_similar() on first result finds all counterparts
        first_price = page.css(".price, .product-price, .current-price")
        if not first_price:
            print(f"  No prices found on page {page_num}")
            continue

        # find_similar adapts to whatever the price class is on this page
        all_prices = first_price[0].find_similar()
        all_names  = page.css("h2, h3").extract()

        print(f"  Page {page_num}: {len(all_prices)} prices, {len(all_names)} names")
        time.sleep(random.uniform(1.5, 3.0))

    return all_products
\n```

\n\n

* * *

## Common Pitfalls and Fixes

`css_first()` **doesn't exist — use** `css()[0]`

\n\n

```python\n# Wrong — AttributeError
item = page.css_first(".price")

# Right
item = page.css(".price")[0] if page.css(".price") else None
\n```

\n\n

`extract()` **returns HTML strings, not text**

\n\n

```python\n# extract() returns '<span class="price">Rs. 999</span>'
raw = page.css(".price").extract()

# For clean text, iterate and use .text
texts = [el.text for el in page.css(".price")]
\n```

\n\n

`find_ancestor()` **takes a callable, not a string**

\n\n

```python\n# Wrong
parent = el.find_ancestor("div")

# Right
parent = el.find_ancestor(lambda e: e.tag == "div")
parent = el.find_ancestor(lambda e: "card" in (e.attrib.get("class") or ""))
\n```

\n\n

* * *

## Summary

| Concept | Method | Example |
| --- | --- | --- |
| Parse HTML | Adaptor(html, url=url) | Build the page object |
| CSS select all | page.css("selector") | Returns Selectors list |
| CSS select one | page.css("sel")[0] | First match |
| Element text | el.text | Clean inner text |
| Attribute | el.attrib["href"] | Safe dict access |
| All text | el.get_all_text(sep) | Concatenated descendants |
| Tag name | el.tag | "div", "span", etc. |
| Outer HTML | el.html | &lt;div&gt;...&lt;/div&gt; |
| XPath | page.xpath("//h2") | Same return type as css() |
| Find by text | page.find_by_text("text") | Class-independent search |
| Partial match | find_by_text("x", partial=True) | Substring search |
| Find similar | el.find_similar() | Adaptive structural match |
| Walk up tree | el.find_ancestor(lambda e: ...) | Callable test |
| Auto CSS | el.generate_css_selector | Discover selectors |
| Auto XPath | el.generate_xpath_selector | Discover selectors |
| Children | el.children | Direct child iterator |
| Raw HTML list | page.css("x").extract() | HTML strings |
| First raw HTML | page.css("x").extract_first() | First HTML string |

* * *

---

*Originally published on [ZyVOP](https://zyvop.com/scrapling-the-python-scraper-that-doesn-t-break-when-sites-change-2026-igvn0)*

💡 For more articles like this, [subscribe to the ZyVOP newsletter](https://zyvop.com/newsletter)!
Enter fullscreen mode Exit fullscreen mode

Top comments (0)