Categories:Scraping Use Cases,Scraping Tools

Web Scraping in CrewAI: Give Your Agents a Tool That Actually Reads the Web

Clock12 Mins Read
calendarCreated Date: July 13, 2026
calendarUpdated Date: July 13, 2026

An agent that can't tell a real page from a block page is worse than an agent that can't browse at all. The one that can't browse fails loudly. The one holding a broken tool fails quietly: it fetches a Cloudflare challenge screen, reads the headings and body text sitting right there in the HTML, and writes you a confident summary of a page it never actually saw.

CrewAI gives every agent a role, a goal, and a set of tools, then lets the crew work the problem together. That model is clean until the moment an agent reaches for the open web, because from that point on the tool you handed it is the agent's entire perception of the page. Hand it requests.get() and it perceives whatever a datacenter IP gets served, which on anything commercial is a fingerprinted block, a rate-limit wall, a CAPTCHA, or an empty shell that JavaScript was supposed to fill.

So this tutorial isn't really about crews. It's about the one file that decides whether your agents see the web or hallucinate it. We'll build ScrapedoTool, a CrewAI custom tool that routes every fetch through Scrape.do, which handles proxy rotation, anti-bot bypass, CAPTCHA solving, and JavaScript rendering on its side. Then we'll prove it out with a small two-agent crew. Write the tool once and every agent in every crew you build afterward inherits a web that answers.

The failure mode you're actually fixing

Naive scraping breaks in four ways, and each one lands on the exact spot agents are weakest.

An agent doesn't experience an IP ban as a ban. It experiences it as a page, reads that page, and moves on. It doesn't experience an anti-bot challenge as a challenge, that Cloudflare interstitial has a title and paragraphs, and the agent will summarize them as if they were the article. A CAPTCHA is a hard stop for a raw request, but the agent only learns that if the tool tells it. And a JavaScript-rendered page comes back as valid markup with no content in it, so the agent reports the page is empty, or worse, invents what it assumes should have been there.

Every one of these is a case where the request technically succeeded and the content is garbage. That's the dangerous class of failure for an autonomous system, and it's exactly what routing through Scrape.do removes: one GET request in, the real rendered page out, no challenge screens leaking into your agent's context.

Two things about that arrangement matter specifically for agents, which retry more than any human ever would:

  • Failed requests are free. You're only billed for successful 2xx responses, so an agent hammering a stubborn target through a retry loop doesn't run up a bill on the attempts that bounce.
  • The free tier is 1,000 requests a month, enough to build and stress-test a crew before you pay for anything. Grab a token here.

What we'll build

A sequential two-agent crew that turns a URL into a structured report:

  • A Web Data Collector fetches the target through Scrape.do, escalating to JavaScript rendering or residential proxies when a page fights back.
  • A Content Analyst reads the retrieved HTML and writes a clean summary: title, topic, key points, notable items.

The crew is the demo. ScrapedoTool is the deliverable, once it exists, any agent you write can reach any page.

Prerequisites

  • Python >=3.10, <3.14
  • A CrewAI project scaffolded with crewai create crew scrapedo_crew
  • A free Scrape.do token from scrape.do
  • An LLM key for your agents (OPENAI_API_KEY or equivalent)

Building the tool

Create src/scrapedo_crew/tools/scrapedo_tool.py. This is the whole integration; every later step is just wiring it into a crew.

"""Scrape.do custom tool for CrewAI.

Wraps the Scrape.do API so an agent can fetch any web page with proxy
rotation, anti-bot bypass, CAPTCHA handling and optional JS rendering.
"""

import os
from typing import Optional, Type
from urllib.parse import quote

import requests
from crewai.tools import BaseTool
from pydantic import BaseModel, Field

SCRAPEDO_ENDPOINT = "https://api.scrape.do/"


class ScrapedoToolInput(BaseModel):
    """Input schema for ScrapedoTool."""

    url: str = Field(..., description="The full URL of the page to scrape.")
    render: bool = Field(
        default=False,
        description=(
            "Set true for JavaScript-heavy pages that need a headless browser "
            "to produce the final HTML. Costs more credits."
        ),
    )
    super_proxy: bool = Field(
        default=False,
        description=(
            "Set true to route through residential/mobile proxies. Use for hard "
            "targets or after a ROTATION_FAILED error. Costs more credits."
        ),
    )


class ScrapedoTool(BaseTool):
    name: str = "scrapedo_web_scraper"
    description: str = (
        "Fetches the HTML content of a web page through the Scrape.do API. "
        "Handles proxy rotation, anti-bot systems, CAPTCHAs and JavaScript "
        "rendering server-side. Use this instead of a plain HTTP request "
        "whenever a page may be protected, rate-limited or JavaScript-rendered."
    )
    args_schema: Type[BaseModel] = ScrapedoToolInput

    api_token: Optional[str] = None
    timeout: int = 60
    max_chars: int = 100_000

    def __init__(self, api_token: Optional[str] = None, **kwargs):
        super().__init__(**kwargs)
        self.api_token = api_token or os.getenv("SCRAPEDO_TOKEN")
        if not self.api_token:
            raise ValueError(
                "Scrape.do token missing. Set the SCRAPEDO_TOKEN environment "
                "variable or pass api_token=... when creating the tool."
            )

    def _run(self, url: str, render: bool = False, super_proxy: bool = False) -> str:
        params = {
            "token": self.api_token,
            # Scrape.do requires the target URL to be encoded.
            "url": quote(url, safe=""),
        }
        if render:
            params["render"] = "true"
        if super_proxy:
            params["super"] = "true"

        try:
            response = requests.get(
                SCRAPEDO_ENDPOINT, params=params, timeout=self.timeout
            )
        except requests.RequestException as exc:
            return f"Scrape.do request failed for {url}: {exc}"

        # Scrape.do only charges for successful (2xx) responses.
        if response.status_code != 200:
            return (
                f"Scrape.do returned status {response.status_code} for {url}. "
                f"Response: {response.text[:500]}"
            )

        content = response.text
        if len(content) > self.max_chars:
            content = content[: self.max_chars] + "\n\n[... truncated ...]"
        return content

Short as it is, four decisions in that file are load-bearing, and getting any of them wrong is the difference between a tool that works and one that quietly poisons the agent's context.

The description is written for the model, not for you. CrewAI feeds description and every field description straight into the agent's context; that text is how the agent decides when to reach for the tool and how to call it. "Use this instead of a plain HTTP request whenever a page may be protected, rate-limited or JavaScript-rendered" is a direct instruction to the model. A vague description is the single most common reason an agent ignores a tool you gave it and improvises with something worse.

render and super_proxy live in the input schema, which puts the escalation decision in the agent's hands per request. Both cost extra credits, so both default off, and the agent flips them on when a page demands it. Judging "this looks like it needs a real browser" is exactly the kind of call agents are good at.

The target URL is encoded with quote(url, safe="") because Scrape.do expects an encoded target. Skip it and any URL carrying its own query string breaks in ways that look like the site's fault rather than an encoding bug.

And errors come back as strings, not exceptions. A raised exception ends the agent's turn; a returned error string tells the agent what went wrong and lets it decide the next move, retry with rendering, escalate to residential proxies, or report the failure honestly. That single choice is what makes the escalation ladder in the task description below actually function. The max_chars ceiling is the fifth quiet guard: raw HTML is enormous, and an untruncated content-heavy page will blow past your model's context window or run up a surprising token bill.

Wiring the crew around it

With the tool built, the rest is configuration. Define the two agents in src/scrapedo_crew/config/agents.yaml:

web_data_collector:
  role: >
    Web Data Collector
  goal: >
    Retrieve the full, unblocked content of the target URL: {url}
  backstory: >
    You are a specialist at pulling web pages that fight back — pages behind
    anti-bot systems, rate limits, CAPTCHAs, or built entirely with JavaScript.
    You always use the Scrape.do tool to fetch content, and you never fabricate
    data you did not actually retrieve. If a page comes back empty or looks
    JavaScript-driven, you retry with rendering enabled. If a request is blocked
    or rotation fails, you retry through residential proxies.

content_analyst:
  role: >
    Content Analyst
  goal: >
    Turn raw scraped HTML into a clean, structured summary a human can act on.
  backstory: >
    You read raw HTML and extract what matters — the page title, the main topic,
    the key facts, and any prices, products or named entities present. You work
    strictly from the content you were given and never invent details that are
    not in the source.

The anti-hallucination language in both backstories isn't filler. "You never fabricate data you did not actually retrieve" and "never invent details that are not in the source" are the guardrail against the precise failure this whole post is about, an agent narrating a block page or filling in content it never received.

Now the tasks, in src/scrapedo_crew/config/tasks.yaml:

scrape_page_task:
  description: >
    Scrape the following URL and return its full content: {url}

    Use the Scrape.do web scraper tool. Start with a plain request.
    If the returned page looks empty, truncated, or is clearly rendered by
    JavaScript, retry the same URL with render set to true.
    If the request is blocked, returns a non-200 status, or reports a rotation
    failure, retry with super_proxy set to true.
    Return the retrieved content exactly as received.
  expected_output: >
    The raw HTML content of the page at {url}.
  agent: web_data_collector

analyze_content_task:
  description: >
    Using the scraped content from the previous task, produce a structured
    summary of the page. Work only from the content that was actually
    retrieved — do not add information from your own knowledge.
  expected_output: >
    A markdown summary with exactly these sections:

    ## Title
    The page title.

    ## Topic
    One or two sentences describing what the page is about.

    ## Key Points
    Three to five bullet points covering the most important facts.

    ## Notable Items
    Any products, prices, or named entities found on the page.
    Write "None found" if there are none.
  agent: content_analyst
  output_file: report.md

The escalation ladder lives in the task description, plain request, then render, then super_proxy. It only works because the tool returns errors as readable strings: the agent sees the failure text and knows which rung to climb next. output_file: report.md drops the analyst's summary straight to disk.

Finally, assemble the crew in src/scrapedo_crew/crew.py:

from typing import List

from crewai import LLM
from crewai import Agent, Crew, Process, Task
from crewai.agents.agent_builder.base_agent import BaseAgent
from crewai.project import CrewBase, agent, crew, task

from scrapedo_crew.tools.scrapedo_tool import ScrapedoTool


@CrewBase
class ScrapedoCrew:
    """Scrape.do web scraping crew."""

    agents: List[BaseAgent]
    tasks: List[Task]

    agents_config = "config/agents.yaml"
    tasks_config = "config/tasks.yaml"

    @agent
    def web_data_collector(self) -> Agent:
        return Agent(
            config=self.agents_config["web_data_collector"],
            tools=[ScrapedoTool()],
            allow_delegation=False,
            verbose=True,
        )

    @agent
    def content_analyst(self) -> Agent:
        return Agent(
            config=self.agents_config["content_analyst"],
            allow_delegation=False,
            verbose=True,
        )

    @task
    def scrape_page_task(self) -> Task:
        return Task(
            config=self.tasks_config["scrape_page_task"],
        )

    @task
    def analyze_content_task(self) -> Task:
        return Task(
            config=self.tasks_config["analyze_content_task"],
            context=[self.scrape_page_task()],
        )

    @crew
    def crew(self) -> Crew:
        return Crew(
            agents=self.agents,
            tasks=self.tasks,
            process=Process.sequential,
            verbose=True,
            chat_llm=LLM(model="openai/gpt-4o"),
        )

ScrapedoTool() goes in the collector's tools list and nowhere else, the analyst carries no tools because it only reasons over what it's handed. context=[self.scrape_page_task()] passes the scraped content forward.

Running it

Set the inputs in src/scrapedo_crew/main.py:

DEFAULT_INPUTS = {
    "url": "https://books.toscrape.com/",
}


def run():
    ScrapedoCrew().crew().kickoff(inputs=DEFAULT_INPUTS)

Drop your keys in .env:

SCRAPEDO_TOKEN=your_scrapedo_token
OPENAI_API_KEY=your_openai_key

Then:

crewai install
crewai run

With verbose=True you'll watch the collector call scrapedo_web_scraper, then the analyst reason over the HTML it returned. The structured summary lands in report.md.

Scaling to a list of URLs

For a batch, kick the crew off once per URL. Each run is independent, so one bad page doesn't take down the rest:

BATCH_URLS = [
    "https://books.toscrape.com/",
    "https://quotes.toscrape.com/",
]


def run_batch():
    results = {}
    for url in BATCH_URLS:
        print(f"\n{'=' * 60}\nProcessing: {url}\n{'=' * 60}")
        try:
            results[url] = ScrapedoCrew().crew().kickoff(inputs={"url": url})
        except Exception as e:
            print(f"Failed on {url}: {e}")
            results[url] = None
    return results

Because only successful responses are billed, a batch with a few unreachable targets costs you just the pages you actually got back.

Scaffolding it visually in Crew Studio

On CrewAI Enterprise, Crew Studio gives you a visual canvas for the same structure: describe the automation in chat and it scaffolds agents, tasks, and tools as editable nodes. It's a fast way to shape a crew, and it hands you a downloadable project.

The Scrape.do tool still comes from this post, though. Scaffold the crew in Studio, download the code, drop scrapedo_tool.py into src/<your_project>/tools/, and add tools=[ScrapedoTool()] to the collector agent. Keep your token in a SCRAPEDO_TOKEN environment variable rather than pasting it into a node.

Things that will bite you

Encode the target URL. quote(url, safe="") is not optional. Targets with query strings fail in ways that look like site problems, not encoding problems.

Mind the context window. Raw HTML is huge. max_chars exists so one content-heavy page can't exhaust your model's context or spike your bill.

If the agent won't use the tool, fix the description. When a collector ignores scrapedo_web_scraper, the answer is almost always in the description string, not the code. Spell out when to use it.

Escalate on purpose. render=true and super=true both cost more. Leave them off by default and let the agent turn them on when a page truly needs a browser or a residential IP, which is what the task's escalation ladder is for.

Keep the token out of your code. Read it from the environment. Never commit it, never paste it into a screenshot or an exported project.

Where to take it next

The tool is the reusable piece; the crew around it is interchangeable. A few directions:

  • Add a parser agent between collector and analyst that extracts fields into a Pydantic model with output_pydantic, and you get typed data instead of prose.
  • Feed a RAG pipeline by piping scraped content into a knowledge source so a downstream crew answers questions grounded in live web data.
  • Hand it to any research crew doing competitive monitoring, price tracking, or lead enrichment, they all take the same tools=[ScrapedoTool()] line.

Wrapping up

CrewAI gives your agents reasoning and collaboration. What it can't give them on its own is a web that answers instead of one that blocks, challenges, and lies by omission. That's one tool file, and once it's in your project, every agent you write reaches any page without you ever touching a proxy config or a browser driver.

Get a free Scrape.do token, 1,000 requests a month, no credit card, and give your crew the open web.

Downloads: