     [Blog](https://scrapfly.io/blog)   /  [python](https://scrapfly.io/blog/tag/python)   /  [How to Scrape Capterra Reviews and Software Data](https://scrapfly.io/blog/posts/how-to-scrape-capterra)   # How to Scrape Capterra Reviews and Software Data

 by [Mohab Yousry](https://scrapfly.io/blog/author/mohab-yousry-9396552a) Sep 11, 2026 19 min read [\#python](https://scrapfly.io/blog/tag/python) [\#scrapeguide](https://scrapfly.io/blog/tag/scrapeguide) 

 [  ](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fscrapfly.io%2Fblog%2Fposts%2Fhow-to-scrape-capterra "Share on LinkedIn") [  ](https://x.com/intent/tweet?url=https%3A%2F%2Fscrapfly.io%2Fblog%2Fposts%2Fhow-to-scrape-capterra&text=How%20to%20Scrape%20Capterra%20Reviews%20and%20Software%20Data "Share on X") [  ](https://www.facebook.com/sharer/sharer.php?u=https%3A%2F%2Fscrapfly.io%2Fblog%2Fposts%2Fhow-to-scrape-capterra "Share on Facebook")    

 

 

Summarize this article with

 [  ](https://chat.openai.com/?q=Summarize%20this%20article%20and%20explain%20how%20Scrapfly%20helps%20me%20scrape%20any%20website%20at%20scale%20and%20bypass%20anti-bot%20systems%20for%20my%20use%20case%3A%20https%3A%2F%2Fscrapfly.io%2Fblog%2Fposts%2Fhow-to-scrape-capterra) [  ](https://claude.ai/new?q=Summarize%20this%20article%20and%20explain%20how%20Scrapfly%20helps%20me%20scrape%20any%20website%20at%20scale%20and%20bypass%20anti-bot%20systems%20for%20my%20use%20case%3A%20https%3A%2F%2Fscrapfly.io%2Fblog%2Fposts%2Fhow-to-scrape-capterra) [  ](https://x.com/i/grok?text=Summarize%20this%20article%20and%20explain%20how%20Scrapfly%20helps%20me%20scrape%20any%20website%20at%20scale%20and%20bypass%20anti-bot%20systems%20for%20my%20use%20case%3A%20https%3A%2F%2Fscrapfly.io%2Fblog%2Fposts%2Fhow-to-scrape-capterra) [  ](https://www.perplexity.ai/search/new?q=Summarize%20this%20article%20and%20explain%20how%20Scrapfly%20helps%20me%20scrape%20any%20website%20at%20scale%20and%20bypass%20anti-bot%20systems%20for%20my%20use%20case%3A%20https%3A%2F%2Fscrapfly.io%2Fblog%2Fposts%2Fhow-to-scrape-capterra) [  ](https://www.google.com/search?udm=50&aep=11&q=Summarize%20this%20article%20and%20explain%20how%20Scrapfly%20helps%20me%20scrape%20any%20website%20at%20scale%20and%20bypass%20anti-bot%20systems%20for%20my%20use%20case%3A%20https%3A%2F%2Fscrapfly.io%2Fblog%2Fposts%2Fhow-to-scrape-capterra) 



         

   **Web Scraping API**Scrape any website with anti-bot bypass, proxy rotation, and JS rendering.

 

 [ Learn More  ](https://scrapfly.io/products/web-scraping-api) [  Docs ](https://scrapfly.io/docs/scrape-api/getting-started) 

 

 

Send a plain request to a Capterra product page and you will not get reviews, you will get a Cloudflare challenge. After bypassing it, selectors tied to hashed CSS-in-JS classes can break when Capterra rebuilds its frontend.

This guide walks through scraping Capterra software listings and product reviews in Python, past Cloudflare, using a selector strategy built on stable DOM hooks that survive Capterra's frequent frontend rebuilds.



[**Capterra Scraper**github.com/scrapfly/scrapfly-scrapers/tree/main/capterra-scraper](https://github.com/scrapfly/scrapfly-scrapers/tree/main/capterra-scraper)

## Key Takeaways

Capterra is a software review directory protected by Cloudflare Bot Management. G2 [acquired it from Gartner](https://company.g2.com/news/g2-acquires-capterra-software-advice-getapp) in a deal announced January 29, 2026 and completed February 5, 2026, and it now sits alongside GetApp and Software Advice under G2 Digital Markets. Scraping it successfully requires JavaScript rendering with an anti-bot bypass. The parser anchors cards and ratings to stable `data-testid` attributes, then scopes the remaining selectors within each card.

- Use Scrapfly's Anti-Scraping Protection with `asp=True` and `render_js=True` to solve Capterra's Cloudflare JavaScript challenge and receive the fully rendered page HTML
- Target stable DOM hooks - `[data-testid^="product-card-container-"]`, `div[data-test-id='review-cards-container']`, and per-metric `data-testid` values like `"Overall Rating-rating"` instead of hashed class names that change between builds
- Extract software name, star rating, review count, descriptions, feature tags, and product URLs from Capterra's category listing pages at `/{category}-software/`
- Pull review title, body, pros, cons, sub-ratings, reviewer role, and date from each product's reviews page at `/p/{product_id}/{slug}/reviews/`
- Page through review batches with the `?page=` parameter to capture more than the first load of results
- Run category and review page requests concurrently through Scrapfly with ASP and residential proxies

**Get web scraping tips in your inbox**Trusted by 100K+ developers and 30K+ enterprises. Unsubscribe anytime.







## Why Scrape Capterra?

`Capterra` hosts verified software reviews across major business categories. The merged scraper extracts review ratings, pros and cons, reviewer role and industry, and category fields including aggregate ratings, review counts, descriptions, and feature tags.

Common use cases:

- **Competitor analysis**: track how rival products are rated and surface recurring pain points from reviewer feedback
- **Rating monitoring**: detect reputation shifts in a product's aggregate score over time
- **ML and sentiment datasets**: one practitioner documented scraping roughly 4,000 Capterra reviews in early 2026 for a chatbot training corpus, noting the need to paginate carefully and append by review timestamp on incremental runs

Popular products carry thousands of reviews across paginated pages. Automating the collection in Python avoids copying each page by hand.

Related review-site scraping guides:

[How to Scrape G2 Company Data and ReviewsIn this scrapeguide we're taking a look at G2.com - one of the biggest digital product metawebsites out there. We'll be scraping product data, reviews and company profiles.](https://scrapfly.io/blog/posts/how-to-scrape-g2-company-data-and-reviews)

[How to Scrape Trustpilot.com Reviews and Company DataIn today's scrapeguide we'll be taking a look at Trustpilot - one of the biggest sources of company reviews and how to scrape it using Python.](https://scrapfly.io/blog/posts/how-to-scrape-trustpilot-com-reviews)

Capterra uses neither pattern. It is a render-and-parse target with Cloudflare as the gatekeeper.



## How to Avoid Capterra Web Scraping Blocking

The main obstacle when scraping Capterra is Cloudflare, which can intercept plain Python requests before they reach the review HTML.

### Why Does Capterra Block Scrapers?

Capterra runs Cloudflare Bot Management across its content pages. A plain `requests` or `httpx` GET can return a `__cf_chl_tk` JavaScript challenge interstitial, a "Just a moment..." page instead of review data. Without a JavaScript engine to resolve the challenge, the scraper never reaches the origin server. The `server: cloudflare` response header and the `__cf_bm` and `cf_clearance` cookies confirm the vendor.

Three failure modes affect Capterra scrapers specifically:

- **Cloudflare JavaScript challenge on content pages.** Product review URLs at `/p/{product_id}/{slug}/reviews/` and category pages at `/{category}-software/` can trigger the JavaScript challenge. When challenged, a plain HTTP client receives the interstitial instead of the page body.
- **Datacenter IP escalation.** Cloudflare scores datacenter IP ranges from AWS, GCP, and Azure harshly. Requests from those ranges hit the challenge immediately.
- **Generic utility classes break naive parsers.** Even after bypassing the challenge, much of Capterra's DOM is styled with reused classes like `text-neutral-90` or `typo-0` that describe appearance, not meaning. A selector that grabs the first match on the page instead of the one inside the current review card returns the wrong value with no visible error.

Capterra's protection is Cloudflare, not DataDome. If you have worked with G2 scraping, the tools and bypass approach are different. For a thorough look at how Cloudflare Bot Management detects scrapers and what each detection layer checks:

[How to Bypass Cloudflare When Web Scraping in 2026Cloudflare offers one of the most popular anti scraping service, so in this article we'll take a look how it works and how to bypass it.](https://scrapfly.io/blog/posts/how-to-bypass-cloudflare-anti-scraping)

### Bypassing Capterra's Cloudflare Protection with Scrapfly

Scrapfly's [Anti-Scraping Protection](https://scrapfly.io/docs/scrape-api/unblocker) solves the Cloudflare challenge and returns the fully rendered HTML. Enabling `asp=True` alongside `render_js=True` in the `ScrapeConfig` is the entire bypass. You receive HTTP 200 with the full review page ready to parse.



python```python
import httpx

response = httpx.get("https://www.capterra.com/p/135003/Slack/reviews/")
print(response.status_code) # 403 or 503
print(response.text[:300])  # "Just a moment..." Cloudflare interstitial HTML

```



python```python
from scrapfly import ScrapeConfig, ScrapflyClient

scrapfly = ScrapflyClient(key="YOUR_SCRAPFLY_API_KEY")

result = scrapfly.scrape(ScrapeConfig(
    url="https://www.capterra.com/p/135003/Slack/reviews/",
    asp=True,                           
    render_js=True,                       
    country="US",                      
    proxy_pool="public_residential_pool", 
))

print(result.upstream_status_code)          # 200
print(len(result.scrape_result["content"])) # full rendered HTML length

```



The validated config for Capterra is `asp=True` + `render_js=True` with a US residential proxy. The [Scrapfly Web Scraping API](https://scrapfly.io/products/web-scraping-api) handles proxy rotation and challenge solving, so no browser automation stack is needed.

With the bypass confirmed, the next step is wiring up the project so every request inherits the right configuration from a single place.



## Project Setup

The scraper uses three packages:

- `scrapfly-sdk` (with the `all` extras): Cloudflare bypass, JavaScript rendering, and a built-in `parsel` selector on every response
- `loguru`: structured logging
- `asyncio`: concurrency for multi-page and multi-product runs (ships with Python)

shell```shell
pip install "scrapfly-sdk[all]" loguru
```



With that installed, set your API key as an environment variable and initialize the Scrapfly client with a shared `BASE_CONFIG` dictionary that every scraper function in this guide passes into its `ScrapeConfig`. This mirrors the setup in the [`capterra-scraper`](https://github.com/scrapfly/scrapfly-scrapers/tree/main/capterra-scraper) reference repo:

shell```shell
export SCRAPFLY_KEY="YOUR_SCRAPFLY_API_KEY"
```



python```python
import os
from loguru import logger as log
from scrapfly import ScrapeConfig, ScrapflyClient, ScrapeApiResponse

SCRAPFLY = ScrapflyClient(key=os.environ["SCRAPFLY_KEY"])

BASE_CONFIG = {
    "asp": True,                           # enable Cloudflare bypass
    "render_js": True,                     # render Next.js for full DOM access
    "country": "US",                       # residential proxy region
    "proxy_pool": "public_residential_pool",
}
```



Centralizing the config in `BASE_CONFIG` means proxy country, ASP settings, and rendering options can be tuned in one place without touching every scraper function.

Every `ScrapeApiResponse` Scrapfly returns exposes a `.selector` property, a ready-to-use `parsel.Selector` built from the rendered HTML, so there is no separate parsing step to wire up. For the full SDK reference, see the [Scrapfly Python SDK documentation](https://scrapfly.io/docs/sdk/python).



## How to Scrape Capterra Software Listings

Category pages live at `/{category}-software/` - for example `/project-management-software/` or `/customer-relationship-management-software/`. Each software card carries the product name, aggregate rating and its breakdown, review count, description, feature tags, and a profile link containing the numeric product ID needed to build the reviews URL. Pagination appends `?page=` to the category URL, and product links follow the stable `/p/` URL pattern.

The validated selector strategy targets each card by its `data-testid` prefix rather than the classes wrapped around it. Each product card sits inside a `[data-testid^="product-card-container-"]` element.

That same `data-testid` attribute embeds the numeric product ID, for example `product-card-container-211559`. A single regex on the attribute gives you the ID without an extra request:



python```python
import re
from typing import Dict, List, Optional, TypedDict
from urllib.parse import urljoin

from scrapfly import ScrapeApiResponse


class CategoryProduct(TypedDict):
    product_id: str
    name: str
    url: str
    reviews_url: str
    logo: Optional[str]
    rating: Optional[float]
    review_count: Optional[int]
    rating_breakdown: Dict[str, Optional[float]]
    description: Optional[str]
    features: List[str]


class CategoryResult(TypedDict):
    total_pages: int
    products: List[CategoryProduct]


def parse_category_page(response: ScrapeApiResponse) -> List[CategoryProduct]:
    """Parse product listings from a Capterra category page."""
    sel = response.selector
    base_url = "https://www.capterra.com"
    products = []

    for card in sel.css('[data-testid^="product-card-container-"]'):
        product_id_match = re.search(
            r"product-card-container-(\d+)",
            card.css("::attr(data-testid)").get(""),
        )
        if not product_id_match:
            continue

        name = card.css('[data-testid^="product-header-"]::text').get()
        if not name:
            continue

        relative_url = card.css(
            'a[data-trk-label="text-link_learn-more"]::attr(href)'
        ).get() or card.css('a[href*="/p/"]::attr(href)').get()
        if not relative_url:
            continue

        url = urljoin(base_url, relative_url)
        reviews_path = card.css('a[href*="/reviews/"]::attr(href)').get()
        reviews_url = urljoin(base_url, reviews_path) if reviews_path else url.rstrip("/") + "/reviews/"

        rating = review_count = None
        review_text = card.css('a[href*="/reviews/"]').xpath("string(.)").get()
        if review_text:
            m = re.search(r"^([\d.]+)", review_text.strip())
            if m:
                rating = float(m.group(1))
            m = re.search(r"\(([\d,]+)\)", review_text)
            if m:
                review_count = int(m.group(1).replace(",", ""))

        card_text = " ".join(t.strip() for t in card.css("::text").getall() if t.strip())
        rating_breakdown = {}
        for label, key in [
            ("Overall", "overall"),
            ("Ease of Use", "ease_of_use"),
            ("Customer Service", "customer_service"),
            ("Features", "features"),
            ("Value for Money", "value_for_money"),
        ]:
            m = re.search(rf"{re.escape(label)}\s+([\d.]+)", card_text)
            rating_breakdown[key] = float(m.group(1)) if m else None

        description = None
        for p in card.css("p"):
            text = "".join(p.css("::text").getall()).strip()
            if text and "features reviewers most value" not in text:
                description = text.split("Learn more about")[0].strip()
                break

        features = [
            t.strip()
            for t in card.css('[data-testid="product-card-category-features"] .flex.items-center::text').getall()
            if t.strip()
        ]

        products.append({
            "product_id": product_id_match.group(1),
            "name": name.strip(),
            "url": url,
            "reviews_url": reviews_url,
            "logo": card.css("img::attr(src)").get(),
            "rating": rating,
            "review_count": review_count,
            "rating_breakdown": rating_breakdown,
            "description": description,
            "features": features,
        })

    return products
```



This goes through every card matching the `product-card-container-` prefix, pulls the numeric product ID out of its `data-testid`, then reads the name, URL, reviews link, rating breakdown, description, and feature tags off that same card into a `CategoryProduct` dict. Cards missing an ID or a name are skipped rather than appended with blank fields.

Rating and review count are not sitting in their own tagged elements. They ride along inside the same link that points to `/reviews/`, as plain text like `4.6 (2,345)`. Pulling the link's full text with `xpath("string(.)")` and running two small regexes against it is more resilient than chasing whatever wrapper span holds the number this build.

Pagination uses the terminal chevron link rather than the visible pager window. The scraper reads the `page=` value from the link containing `i[aria-label="chevron-line-right"]`, which exposes the discovered total even when only a subset of page numbers is visible:



python```python
from loguru import logger as log
from scrapfly import ScrapeConfig, ScrapflyClient

SCRAPFLY = ScrapflyClient(key="YOUR_SCRAPFLY_API_KEY")


def _get_total_pages(response: ScrapeApiResponse) -> int:
    href = response.selector.xpath(
        '//a[.//i[@aria-label="chevron-line-right"]]/@href'
    ).get()
    if not href:
        return 1
    match = re.search(r"page=(\d+)", href)
    return int(match.group(1)) if match else 1


def _pages_to_scrape(total_pages: int, max_pages: int) -> int:
    if max_pages < 0:
        raise ValueError("max_pages must be 0 or greater")
    if max_pages == 0:
        return total_pages
    return min(max_pages, total_pages)


async def scrape_category(category: str, max_pages: int) -> CategoryResult:
    """Scrape category listings. Pass max_pages=0 to scrape all pages."""
    base_url = f"https://www.capterra.com/{category}/"
    log.info(f"scraping category page {base_url}")

    first_page = await SCRAPFLY.async_scrape(ScrapeConfig(base_url, **BASE_CONFIG))
    products = parse_category_page(first_page)
    total_pages = _get_total_pages(first_page)
    pages_to_scrape = _pages_to_scrape(total_pages, max_pages)

    if pages_to_scrape > 1:
        log.info(
            f"scraping category pagination, remaining ({pages_to_scrape - 1}) more pages"
        )
        to_scrape = [
            ScrapeConfig(f"{base_url}?page={page}", **BASE_CONFIG)
            for page in range(2, pages_to_scrape + 1)
        ]
        async for response in SCRAPFLY.concurrent_scrape(to_scrape):
            try:
                products.extend(parse_category_page(response))
            except Exception as exc:
                log.error(f"failed to parse page: {exc}")

    log.success(f"scraped {len(products)} products from Capterra category '{category}'")
    return {
        "total_pages": total_pages,
        "products": products,
    }
```



This discovers the full page count from the terminal chevron, keeps that total separate from the requested cap, and returns both values in a `CategoryResult`. Pass `max_pages=3` to cap the run at three pages, or `max_pages=0` to scrape every discovered page. A negative value raises `ValueError`.

The function returns `{"total_pages": int, "products": [...]}`. The repo's `run.py` writes only `category_data["products"]` to `results/category.json`, so the saved file is a list. The excerpt below shows one saved product with volatile ratings and counts omitted:



Saved category.json excerptjson```json
[
  {
    "product_id": "5923",
    "name": "Celoxis",
    "url": "https://www.capterra.com/p/5923/Celoxis/",
    "reviews_url": "https://www.capterra.com/p/5923/Celoxis/reviews/"
  }
]
```







For more on writing resilient selectors, these guides cover CSS and XPath:

[Parsing HTML with CSS SelectorsIntroduction to using CSS selectors to parse web-scraped content. Best practices, available tools and common challenges by interactive examples.](https://scrapfly.io/blog/posts/parsing-html-with-css)

[Parsing HTML with XpathIntroduction to xpath in the context of web-scraping. How to extract data from HTML documents using xpath, best practices and available tools.](https://scrapfly.io/blog/posts/parsing-html-with-xpath)

Once you have product IDs and reviews URLs from the category pages, the next step is pulling reviews from each product's dedicated reviews page.



## How to Scrape Capterra Product Reviews

Product review pages follow `/p/{product_id}/{slug}/reviews/` for example `https://www.capterra.com/p/211559/Trello/reviews/`. Take the ID and slug from the `url` or `reviews_url` your listing scrape already built rather than reconstructing them by hand.

Each review sits inside `div[data-test-id='review-cards-container'] > div > div` note the hyphenated `data-test-id`, unlike the category cards' `data-testid`. From there: reviewer name, role, and industry come out as text nodes inside a `div` with classes `text-neutral-90` and `w-full`, the title sits in `h3.font-semibold`, the five sub-ratings are each keyed by a `data-testid` like `"Overall Rating-rating"`.

Pros and cons need one more layer of care. The merged parser identifies them by the current SVG `<title>` values "Positive icon" and "Negative icon" inside each `.space-y-2` block, rather than by the visible "Pros" and "Cons" labels:



python```python
class ReviewRatings(TypedDict):
    overall: Optional[float]
    ease_of_use: Optional[float]
    features: Optional[float]
    value_for_money: Optional[float]
    customer_service: Optional[float]
    likelihood_to_recommend: Optional[int]


class Review(TypedDict):
    title: str
    date: Optional[str]
    reviewer_name: str
    reviewer_role: Optional[str]
    reviewer_industry: Optional[str]
    reviewer_usage_duration: Optional[str]
    reviewer_avatar: Optional[str]
    ratings: ReviewRatings
    review_body: Optional[str]
    pros: Optional[str]
    cons: Optional[str]


class ReviewResult(TypedDict):
    total_pages: int
    reviews: List[Review]


def _parse_rating_value(card, testid: str) -> Optional[float]:
    """Extract the numeric rating value for a given data-testid rating element."""
    text = card.css(f'[data-testid="{testid}"] span:nth-child(2)::text').get()
    if text:
        try:
            return float(text.strip())
        except ValueError:
            pass
    return None


def parse_review_page(response: ScrapeApiResponse) -> List[Review]:
    """Parse product reviews from a Capterra review page."""
    sel = response.selector
    reviews = []

    for card in sel.css("div[data-test-id='review-cards-container'] > div > div"):
        reviewer_texts = [
            t.strip()
            for t in card.xpath(
                './/div[contains(@class,"text-neutral-90") and contains(@class,"w-full")]//text()'
            ).getall()
            if t.strip()
        ]

        name = reviewer_texts[0] if reviewer_texts else ""
        role = reviewer_texts[1] if len(reviewer_texts) > 1 else None
        industry = reviewer_texts[2] if len(reviewer_texts) > 2 else None

        usage_duration = None
        for i, text in enumerate(reviewer_texts):
            if "used the software for" in text.lower() and i + 1 < len(reviewer_texts):
                usage_duration = reviewer_texts[i + 1]
                if industry and "used the software for" in industry.lower():
                    industry = reviewer_texts[2] if len(reviewer_texts) > 2 else None
                break

        avatar = card.css('img[data-testid="reviewer-profile-pic"]::attr(src)').get()

        title = card.css("h3.font-semibold::text").get("").strip()
        date = card.css(".typo-0.text-neutral-90::text").get()

        likelihood_raw = card.css('progress[max="10"]::attr(value)').get()
        likelihood = int(likelihood_raw) if likelihood_raw else None

        ratings: ReviewRatings = {
            "overall": _parse_rating_value(card, "Overall Rating-rating"),
            "ease_of_use": _parse_rating_value(card, "Ease of Use-rating"),
            "features": _parse_rating_value(card, "Features-rating"),
            "value_for_money": _parse_rating_value(card, "Value for Money-rating"),
            "customer_service": _parse_rating_value(card, "Customer Service-rating"),
            "likelihood_to_recommend": likelihood,
        }

        review_body = card.xpath(
            './/div[contains(@class,"!mt-4")]//p[1]'
        ).xpath("string(.)").get()

        pros = cons = None
        for section in card.css(".space-y-2"):
            icon_title = section.css("title::text").get()
            if icon_title == "Positive icon":
                pros = section.css("p").xpath("string(.)").get()
            elif icon_title == "Negative icon":
                cons = section.css("p").xpath("string(.)").get()

        reviews.append(
            Review(
                title=title,
                date=date.strip() if date else None,
                reviewer_name=name,
                reviewer_role=role,
                reviewer_industry=industry,
                reviewer_usage_duration=usage_duration,
                reviewer_avatar=avatar,
                ratings=ratings,
                review_body=review_body.strip() if review_body else None,
                pros=pros.strip() if pros else None,
                cons=cons.strip() if cons else None,
            )
        )

    return reviews
```



Scrapfly

#### Scale your web scraping effortlessly

Scrapfly handles proxies, browsers, and anti-bot bypass — so you can focus on data.

[Try Free →](https://scrapfly.io/register)Here we read each review card's reviewer info, title, date, sub-ratings, and pros/cons into a `Review` dict. One exception: likelihood-to-recommend comes from a `<progress max="10">` element's `value` attribute, not text, so it reads as an integer while the star ratings read as floats.



### How to Handle Capterra Review Pagination

A single rendered page returns only the first page of reviews. Pagination appends `?page=` to the reviews URL, and the scraper discovers the total from the terminal chevron link at `//a[.//i[@aria-label="chevron-line-right"]]/@href`. The explicit `max_review_pages` value controls how many of those discovered pages are fetched.



python```python
async def scrape_reviews(url: str, max_review_pages: int) -> ReviewResult:
    """Scrape reviews. Pass max_review_pages=0 to scrape all pages."""
    url = url.rstrip("/")
    if not url.endswith("/reviews"):
        url += "/reviews"
    url += "/"

    log.info(f"scraping reviews from {url}")
    first_page = await SCRAPFLY.async_scrape(ScrapeConfig(url, **BASE_CONFIG))
    reviews = parse_review_page(first_page)
    total_pages = _get_total_pages(first_page)
    pages_to_scrape = _pages_to_scrape(total_pages, max_review_pages)

    if pages_to_scrape > 1:
        log.info(
            f"scraping reviews pagination, remaining ({pages_to_scrape - 1}) more pages"
        )
        to_scrape = [
            ScrapeConfig(f"{url}?page={page}", **BASE_CONFIG)
            for page in range(2, pages_to_scrape + 1)
        ]
        async for response in SCRAPFLY.concurrent_scrape(to_scrape):
            try:
                reviews.extend(parse_review_page(response))
            except Exception as exc:
                log.error(f"failed to parse reviews page: {exc}")

    log.success(f"scraped {len(reviews)} reviews from {url}")
    return {
        "total_pages": total_pages,
        "reviews": reviews,
    }
```



This returns a `ReviewResult` with the discovered total in `total_pages` and collected rows in `reviews`. Pass `max_review_pages=3` to cap the run at three pages, or `max_review_pages=0` to scrape every discovered page. A negative value raises `ValueError`.

The repo's `run.py` writes only `reviews_data["reviews"]` to `results/reviews.json`, so the saved fixture remains a list of review dicts. The excerpt below is one item from that inner list:



Saved reviews.json excerptjson```json
[
  {
    "title": "\"Great for project management\"",
    "date": "May 25, 2026",
    "reviewer_name": "Gabby W.",
    "ratings": {
      "overall": 5.0,
      "ease_of_use": 5.0,
      "features": 5.0,
      "value_for_money": 5.0,
      "customer_service": null,
      "likelihood_to_recommend": 10
    }
  }
]
```







Fields like `customer_service` can land as `null` when a reviewer skips that sub-rating, so don't assume every key holds a value.

For a product with thousands of reviews, store the newest `date` seen per run and stop once a later run reaches that cutoff, so repeat runs only fetch new content. A practitioner used this incremental-by-timestamp approach for a 4,000-review chatbot corpus in early 2026.

For async concurrency patterns at scale, see:

[Web Scraping Speed: Processes, Threads and AsyncScaling web scrapers can be difficult - in this article we'll go over the core principles like subprocesses, threads and asyncio and how all of that can be used to speed up web scrapers dozens to hundreds of times.](https://scrapfly.io/blog/posts/web-scraping-speed)



## Extracting Capterra Data Without Writing Selectors

If maintaining selectors is not worth the effort for your use case, Scrapfly's AI Extraction API is an alternative: pass the rendered HTML to an auto model or prompt and get back structured data or LLM-ready Markdown.

When to use each approach:

- **Use AI extraction** for one-off pulls, rapid prototyping, or pipelines feeding directly into an LLM
- **Use selectors** for high-volume runs with a fixed schema, faster and cheaper at scale



python```python
from scrapfly import ExtractionConfig

# reuse the rendered HTML from an earlier ScrapeConfig call
html = result.scrape_result["content"]

extraction_result = scrapfly.extract(ExtractionConfig(
    body=html,
    content_type="text/html",
    url="https://www.capterra.com/p/211559/Trello/reviews/",
    extraction_model="review_list",  # built-in auto model for review pages
))

print(extraction_result.data)
```



This feeds the already-rendered HTML into the `review_list` auto model instead of hand-written selectors, and `extraction_result.data` comes back as structured review data. Swap in `extraction_prompt` for a custom instruction or Markdown output.

[How to Scrape Hidden Web DataThe visible HTML doesn't always represent the whole dataset available on the page. In this article, we'll be taking a look at scraping of hidden web data. What is it and how can we scrape it using Python?](https://scrapfly.io/blog/posts/how-to-scrape-hidden-web-data)



## FAQ

Does Capterra have a public API for reviews?Capterra does not document a public reviews/listings API. For public review and product data, the rendered pages are the available route documented in this guide.







Is it legal to scrape Capterra?Capterra reviews are publicly visible data, and scraping public data for research or analysis is generally permissible, but you are responsible for respecting Capterra's terms, copyright, and personal-data rules in your jurisdiction.







Why does my Capterra scraper get blocked by Cloudflare?Capterra runs Cloudflare Bot Management, so plain requests hit a JavaScript challenge. Enabling Scrapfly's ASP with JavaScript rendering solves the challenge automatically.







How do I scrape Capterra reviews at scale without getting blocked?Use concurrent requests with residential proxies through ASP, handle retryable failures explicitly, and append reviews by timestamp so repeat runs only fetch new ones.









## Summary

Capterra's Cloudflare Bot Management is the real barrier. `asp=True` with `render_js=True` in Scrapfly's ScrapeConfig solves the challenge and returns rendered HTML ready to parse.

Target stable `data-testid` hooks, not reused utility classes. Paginate with `?page=` and go incremental-by-timestamp for repeat runs. [Scrapfly's Web Scraping API](https://scrapfly.io/products/web-scraping-api) handles the bypass, proxies, and rendering in one call.



### Web Scraping API

Scrape any website with our powerful API. Anti-bot bypass, JavaScript rendering, and rotating proxies built-in.



[Try Web Scraping API](https://scrapfly.io/docs/scrape-api/getting-started)



Legal Disclaimer and PrecautionsThis tutorial covers popular web scraping techniques for education. Interacting with public servers requires diligence and respect:

- Do not scrape at rates that could damage the website.
- Do not scrape data that's not available publicly.
- Do not store PII of EU citizens protected by GDPR.
- Do not repurpose *entire* public datasets which can be illegal in some countries.

Scrapfly does not offer legal advice but these are good general rules to follow. For more you should consult a lawyer.

 

   [  Add as a preferred source ](https://google.com/preferences/source?q=scrapfly.io) Table of Contents















 

  Table of Contents- [Key Takeaways](#key-takeaways)
- [Why Scrape Capterra?](#why-scrape-capterra)
- [How to Avoid Capterra Web Scraping Blocking](#how-to-avoid-capterra-web-scraping-blocking)
- [Why Does Capterra Block Scrapers?](#why-does-capterra-block-scrapers)
- [Bypassing Capterra's Cloudflare Protection with Scrapfly](#bypassing-capterra-s-cloudflare-protection-with-scrapfly)
- [Project Setup](#project-setup)
- [How to Scrape Capterra Software Listings](#how-to-scrape-capterra-software-listings)
- [How to Scrape Capterra Product Reviews](#how-to-scrape-capterra-product-reviews)
- [How to Handle Capterra Review Pagination](#how-to-handle-capterra-review-pagination)
- [Extracting Capterra Data Without Writing Selectors](#extracting-capterra-data-without-writing-selectors)
- [FAQ](#faq)
- [Summary](#summary)
 
    Join the Newsletter  Get monthly web scraping insights 

 

  



Scale Your Web Scraping

Anti-bot bypass, browser rendering, and rotating proxies, all in one API. Start with 1,000 free credits.

  No credit card required  1,000 free API credits  Anti-bot bypass included 

 [Start Free](https://scrapfly.io/register) [View Docs](https://scrapfly.io/docs/onboarding) 

 Not ready? Get our newsletter instead. 

 

 ## Related Articles

 [  

 blocking 

### How to Bypass Cloudflare When Web Scraping in 2026

Cloudflare offers one of the most popular anti scraping service, so in this article we'll take a look how it works and h...

 

 ](https://scrapfly.io/blog/posts/how-to-bypass-cloudflare-anti-scraping) [     

 python scrapeguide 

### How to Scrape IMDb Data, Ratings, and Featured Reviews

Fetch protected IMDb pages through Scrapfly ASP, then parse JSON-LD and \_\_NEXT\_DATA\_\_ for movie metadata, ratings, featu...

 

 ](https://scrapfly.io/blog/posts/how-to-scrape-imdb) [     

 python blocking 

### How Cloudflare Detects Bots: TLS, HTTP/2, Canvas, and Turnstile Explained

Learn how Cloudflare detects bots using TLS, HTTP/2, Canvas, WebGL, behavioral analysis, and Turnstile, plus how to scra...

 

 ](https://scrapfly.io/blog/posts/how-cloudflare-detects-bots) 

  



   



 Scale your web scraping effortlessly, **1,000 free credits** [Start Free](https://scrapfly.io/register)