<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Zenrows]]></title><description><![CDATA[Engineering notes on web data infrastructure for AI]]></description><link>https://zenrows.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6a54cb5b6cccea2a6bc94bc9/e0278424-4739-4bba-9d0a-c170b8c5fd36.png</url><title>Zenrows</title><link>https://zenrows.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 15 Sep 2026 22:18:44 GMT</lastBuildDate><atom:link href="https://zenrows.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Integrating Zenrows into smolagents for production web access]]></title><description><![CDATA[This article was originally published on the Zenrows blog. Read the original here: https://www.zenrows.com/blog/zenrows-smolagents

Architecture overview
smolagents' VisitWebpageTool is built on Pytho]]></description><link>https://zenrows.hashnode.dev/integrating-zenrows-into-smolagents-for-production-web-access</link><guid isPermaLink="true">https://zenrows.hashnode.dev/integrating-zenrows-into-smolagents-for-production-web-access</guid><category><![CDATA[smolagents]]></category><category><![CDATA[huggingface]]></category><category><![CDATA[zenrows]]></category><category><![CDATA[AI]]></category><category><![CDATA[ai agents]]></category><category><![CDATA[web scraping]]></category><dc:creator><![CDATA[Praise James]]></dc:creator><pubDate>Mon, 14 Sep 2026 11:57:56 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a54cb5b6cccea2a6bc94bc9/6c6e04aa-c7e6-4d73-ae4f-774feffc2c30.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p><em>This article was originally published on the Zenrows blog. Read the original here: <a href="https://www.zenrows.com/blog/zenrows-smolagents">https://www.zenrows.com/blog/zenrows-smolagents</a></em></p>
</blockquote>
<h2>Architecture overview</h2>
<p>smolagents' <code>VisitWebpageTool</code> is built on Python's <code>requests</code> library. That means every fetch call carries the HTTP client fingerprint of a headless Python process. On protected or JavaScript-rendered pages, the target site identifies the request as automated traffic at the TLS handshake level and returns a verification challenge instead of content.</p>
<p>The problem is not that the tool fails visibly. It fails silently. The agent receives the challenge page and cannot distinguish it from real data, so it reasons over bad input and still returns output. In production workflows, that means research, summarization, and classification tasks all run correctly against the wrong content.</p>
<p>This guide replaces <code>VisitWebpageTool</code> with a Zenrows fetch tool registered via the smolagents <code>@tool</code> decorator. Zenrows sits between your agent and the target site, handles JavaScript rendering and bot detection transparently using Adaptive Stealth Mode, and returns clean Markdown. Your <code>CodeAgent</code> architecture stays unchanged.</p>
<p><strong>What you'll implement:</strong></p>
<ul>
<li><p>A <code>fetch_page</code> tool built on Zenrows Fetch with <code>mode=auto</code></p>
</li>
<li><p>A docstring structured to drive correct tool selection at runtime</p>
</li>
<li><p>A <code>CodeAgent</code> with <code>fetch_page</code> registered in its tools list</p>
</li>
<li><p>Concurrent Hugging Face Spaces handling via Zenrows routing</p>
</li>
</ul>
<p>Full project code: <a href="https://github.com/ZenRows/smolagents-zenrows-tutorial">GitHub repository</a></p>
<h2>Why VisitWebpageTool breaks in production</h2>
<p>Here is what <code>VisitWebpageTool</code> returned when run against a protected Walmart product page:</p>
<pre><code class="language-text">Robot or human?
===============

Activate and hold the button to confirm that you're human. Thank You


(c) Walmart Stores, Inc.
</code></pre>
<p>The TLS handshake exposed the Python HTTP client, and Walmart returned a verification page. The agent received that, had no mechanism to validate it, and would have reasoned from it.</p>
<p><code>max_output_length</code> on <code>VisitWebpageTool</code> controls truncation, not retrieval. Raising it does not change how the tool fetches pages. The fix is at the retrieval layer.</p>
<h2>Prerequisites</h2>
<ul>
<li><p>Python 3.10 or later</p>
</li>
<li><p>A <a href="https://app.zenrows.com/register">Zenrows account</a> for your API key</p>
</li>
<li><p>A <a href="https://huggingface.co/">Hugging Face account</a> for the model access token</p>
</li>
</ul>
<h2>Step 1: Install packages</h2>
<pre><code class="language-bash">pip install smolagents requests python-dotenv
</code></pre>
<h2>Step 2: Configure credentials</h2>
<pre><code class="language-bash"># .env
ZENROWS_API_KEY=your_zenrows_api_key_here
HF_TOKEN=your_hugging_face_token_here
</code></pre>
<h2>Step 3: Build the Zenrows fetch tool</h2>
<p>Create <code>zenrows_tool.py</code>. The <code>@tool</code> decorator is the registration mechanism. It is what makes <code>fetch_page</code> visible to <code>CodeAgent</code> during planning. <code>mode=auto</code> delegates retrieval strategy selection to Zenrows' Adaptive Stealth Mode, which chooses between direct fetch, headless browser, and proxy routing based on how the target responds.</p>
<pre><code class="language-python">import os

import requests
from dotenv import load_dotenv
from smolagents import tool

load_dotenv()

# Load the API key once at module level, not inside the function,
# so it does not trigger a file read on every tool call.
ZENROWS_API_KEY = os.getenv("ZENROWS_API_KEY")

@tool
def fetch_page(url: str) -&gt; str:
    """
    Fetches webpage content and returns it as clean Markdown, including
    JavaScript-rendered and protected pages.

    Use this tool whenever you need to read a specific URL and retrieve
    webpage content for research, summarization, or analysis.

    Args:
        url: The webpage URL to fetch.
    """
    try:
        response = requests.get(
            "https://api.zenrows.com/v1/",
            params={
                "url": url,
                "apikey": ZENROWS_API_KEY,
                "mode": "auto",           # Adaptive Stealth Mode — picks retrieval strategy per target
                "response_type": "markdown",  # Return clean Markdown, not raw HTML
            },
            timeout=30,
        )
        response.raise_for_status()
        return response.text

    except requests.RequestException as exc:
        raise RuntimeError(f"Failed to retrieve content from {url}") from exc
</code></pre>
<h2>Why this approach: the docstring drives tool selection</h2>
<p>smolagents constructs the tool description the model sees at inference time from the function name, type hints, and docstring. The model consults that description when deciding which tool to call and how to call it. A vague docstring produces incorrect behavior. The agent skips the tool, calls it with the wrong argument type, or falls back to writing its own fetch code.</p>
<p>The docstring in <code>zenrows_tool.py</code> specifies three things explicitly:</p>
<ol>
<li><p>What the tool returns: clean Markdown, including from JS-rendered and protected pages</p>
</li>
<li><p>When to use it: any time the agent needs to read a specific URL</p>
</li>
<li><p>What kinds of pages it handles: JavaScript-rendered and protected targets</p>
</li>
</ol>
<p>Without that specificity, the model cannot reliably route retrieval tasks to <code>fetch_page</code>.</p>
<h2>Step 4: Test the fetch tool</h2>
<p>Verify the tool returns real content before wiring it to an agent:</p>
<pre><code class="language-python"># Add to the bottom of zenrows_tool.py
if __name__ == "__main__":
    # Test against the same protected Walmart page that failed with VisitWebpageTool
    result = fetch_page("https://www.walmart.com/ip/AirPods-Pro-3/17835006350")
    # Print a mid-page slice to confirm product content, not a bot check
    print(result[1500:2500])
</code></pre>
<pre><code class="language-bash">python zenrows_tool.py
</code></pre>
<p>The output should contain Markdown from the product page, not a verification challenge.</p>
<h2>Step 5: Register the tool and run the CodeAgent</h2>
<p>Create <code>agent.py</code>. The <code>CodeAgent</code> receives <code>fetch_page</code> in its <code>tools</code> list. During a run, the model plans in Python, calls <code>fetch_page</code> when it needs page content, and structures its output from what Zenrows returns.</p>
<pre><code class="language-python">import os

from dotenv import load_dotenv
from smolagents import CodeAgent, InferenceClientModel

from zenrows_tool import fetch_page

load_dotenv()

# Initialize the model via Hugging Face Inference Providers.
# Swap in any compatible model_id.
model = InferenceClientModel(
    model_id="Qwen/Qwen2.5-7B-Instruct",
    token=os.getenv("HF_TOKEN"),
)

# Register fetch_page as the only retrieval tool.
# The agent will call it for any URL it needs to read.
agent = CodeAgent(
    tools=[fetch_page],
    model=model,
)

response = agent.run(
    """
    Go to https://techcrunch.com/2026/07/23/amd-takes-on-nvidia-with-its-helios-ai-rack-scale-system/

    Read the article and identify:
    - the company involved,
    - the main announcement,
    - the news category,
    - why it matters.
    """,
    max_steps=8,  # Cap steps to avoid unnecessary token spend. Default is 20.
)

print(response)
</code></pre>
<pre><code class="language-bash">python agent.py
</code></pre>
<p>The run finished in three steps. The agent called <code>fetch_page</code> once, received valid content, and structured its output:</p>
<pre><code class="language-text">{'company': 'AMD',
 'main_announcement': 'AMD has launched the Helios AI rack-scale system, challenging Nvidia in the AI rack-scale system market.',
 'news_category': 'AI',
 'why_it_matters': 'AMD is challenging Nvidia in the AI rack-scale system market, which is a significant move as Nvidia has historically dominated this market.'}
</code></pre>
<h2>Handling concurrent Hugging Face Spaces deployments</h2>
<p>Shared Hugging Face Spaces route all outbound requests through a shared pool of managed IP addresses. Sites that enforce per-IP rate limits see all Spaces traffic as one source. Under concurrent usage, that produces slow responses, temporary blocks, or verification challenges at scale.</p>
<p><code>fetch_page</code> routes requests through Zenrows rather than directly from your Space to the target. <code>mode=auto</code> is already set in the params block, so the agent retrieves live content under concurrent load without proxy configuration of your own.</p>
<pre><code class="language-python">params={
    "url": url,
    "apikey": ZENROWS_API_KEY,
    "mode": "auto",            
    "response_type": "markdown",
}
</code></pre>
<h2>What to build next</h2>
<p>The <code>@tool</code> pattern generalises across agent frameworks:</p>
<ul>
<li><p><a href="https://www.zenrows.com/blog/web-aware-agent-openai-agents-sdk-zenrows">OpenAI Agents SDK</a>: uses <code>@function_tool</code> in place of <code>@tool</code>, same retrieval pattern</p>
</li>
<li><p><a href="https://www.zenrows.com/blog/web-research-multi-agent-ag2-zenrows">AG2 multi-agent system</a>: typed <code>Tool</code> registered on two agents in a group chat</p>
</li>
<li><p><a href="https://www.zenrows.com/blog/pull-web-data-into-claude-cursor-and-any-ai-agent-with-zenrows-mcp">LlamaIndex RAG pipeline</a>: indexes and queries live web content using the same Zenrows Fetch layer</p>
</li>
</ul>
<p>If you prefer not to manage the tool wrapper yourself, the <a href="https://docs.zenrows.com/mcp/overview">Zenrows MCP server</a> exposes the same capabilities and smolagents supports loading tools from an MCP server directly.</p>
<p>Full project: <a href="https://github.com/ZenRows/smolagents-zenrows-tutorial">GitHub repository</a></p>
]]></content:encoded></item><item><title><![CDATA[Web data for LLM fine-tuning: a practical 2026 guide]]></title><description><![CDATA[This article was originally published on the Zenrows blog. Read the original here: https://www.zenrows.com/blog/web-data-llm-fine-tuning

Architecture overview
LLM fine-tuning pipelines that rely enti]]></description><link>https://zenrows.hashnode.dev/web-data-for-llm-fine-tuning-a-practical-2026-guide</link><guid isPermaLink="true">https://zenrows.hashnode.dev/web-data-for-llm-fine-tuning-a-practical-2026-guide</guid><category><![CDATA[zenrows]]></category><category><![CDATA[llm]]></category><category><![CDATA[finetuning]]></category><category><![CDATA[web scraping]]></category><category><![CDATA[AI]]></category><category><![CDATA[ai agents]]></category><dc:creator><![CDATA[Praise James]]></dc:creator><pubDate>Mon, 14 Sep 2026 11:55:45 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a54cb5b6cccea2a6bc94bc9/c3884371-5da7-4d6c-9000-bad2a26941c7.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p><em>This article was originally published on the Zenrows blog. Read the original here: <a href="https://www.zenrows.com/blog/web-data-llm-fine-tuning">https://www.zenrows.com/blog/web-data-llm-fine-tuning</a></em></p>
</blockquote>
<h2>Architecture overview</h2>
<p>LLM fine-tuning pipelines that rely entirely on synthetic data are structurally unstable. The generator produces examples using the teacher model's patterns; the judge filters against those same patterns. Each retrain cycle amplifies the biases from the previous one. By the third or fourth cycle, the model has learned to reproduce domain text rather than to reflect domain knowledge.</p>
<p>The intervention is a real-data seed, a small, verified set of examples collected from authoritative sources before any synthetic expansion begins. The seed does two things. It gives the generator concrete domain examples to expand from rather than its own patterns. It gives the judge something to filter against that is not its own output.</p>
<p>Fine-tuning amplifies signal differently than pretraining. A single mislabeled extraction in a dataset of a few hundred rows represents a measurable percentage of the training signal, and the model takes it as a rule. This guide covers collecting that seed from sources that block standard crawlers, formatting it correctly, validating each row, and measuring whether the fine-tune held.</p>
<p><strong>What you'll implement:</strong></p>
<ul>
<li><p>Protected web data collection using Zenrows Fetch with <code>mode=auto</code></p>
</li>
<li><p>PDF retrieval and structured chunking</p>
</li>
<li><p>Provenance tracking per source document</p>
</li>
<li><p>Concurrent batch collection with rate limit management</p>
</li>
<li><p>Three fine-tuning data formats: instruction-output pairs, preference pairs, domain continuation</p>
</li>
<li><p>Seed validation with length, schema, and deduplication checks</p>
</li>
<li><p>Holdout split and accuracy measurement</p>
</li>
</ul>
<p>All code: <a href="https://github.com/ZenRows/web-data-for-llm-fine-tuning">GitHub repository</a></p>
<h2>Why authoritative sources are the hard part</h2>
<p>Authoritative domain sources like regulatory filings, clinical guidance, technical documentation, expert-reviewed publications, are almost always behind access controls. They block standard crawlers at the TLS handshake level. A plain <code>requests.get()</code> exposes the Python HTTP client fingerprint, the target identifies it as automated traffic, and the response is a challenge page or an empty shell.</p>
<p>This is not a scraping problem that can be solved with headers or user-agent rotation alone. JavaScript-rendered pages require a headless browser context. Protected sources require IP rotation and fingerprint masking at the session level. The collection layer has to handle both, per request, without per-site configuration.</p>
<h2>Prerequisites</h2>
<ul>
<li><p>Python 3.9 or later</p>
</li>
<li><p><a href="https://app.zenrows.com/register">Zenrows account</a></p>
</li>
<li><p><code>pypdf</code> for PDF text extraction</p>
</li>
</ul>
<pre><code class="language-bash">python3 -m pip install requests python-dotenv pypdf jsonschema
</code></pre>
<pre><code class="language-bash"># .env — add to .gitignore before committing
ZENROWS_API_KEY=your_zenrows_api_key_here
</code></pre>
<h2>Collecting from protected sources</h2>
<p>Zenrows Fetch handles JavaScript rendering, access control, and Markdown conversion in one API call. <code>mode=auto</code> is Adaptive Stealth Mode. Zenrows selects the retrieval configuration per target based on how that target responds, alternating between direct fetch, headless browser, and proxy escalation as needed.</p>
<pre><code class="language-python">import os
import requests
from dotenv import load_dotenv

load_dotenv()

# Load at module level — one environment read, not one per request
apikey = os.getenv("ZENROWS_API_KEY")

params = {
    "url": "https://www.scrapingcourse.com/ecommerce/",
    "apikey": apikey,
    "mode": "auto",              # Adaptive Stealth Mode picks retrieval strategy per target
    "response_type": "markdown", # Returns clean Markdown, not raw HTML
}

response = requests.get("https://api.zenrows.com/v1/", params=params)
print(response.text)
</code></pre>
<p>The Markdown output strips most navigation, footer, and boilerplate. That reduced noise is what makes it a better training input than raw HTML.</p>
<h2>Handling PDFs and non-HTML documents</h2>
<p>The most authoritative domain sources are often PDFs. <code>response_type=pdf</code> returns raw bytes; <code>js_render=true</code> is required and must be included explicitly.</p>
<pre><code class="language-python">params = {
    "url": pdf_url,
    "apikey": apikey,
    "js_render": "true",    # Required for PDF response type
    "response_type": "pdf", # Returns raw bytes — extract text separately
}

response = requests.get("https://api.zenrows.com/v1/", params=params)

with open("source-document.pdf", "wb") as f:
    f.write(response.content)
</code></pre>
<h3>Why this approach: chunk on structure, not character count</h3>
<p>A regulatory filing can run to tens of thousands of tokens. Fixed-character chunking cuts mid-sentence, mid-argument, and mid-table. Chunking on the document's own page boundaries preserves context. Each chunk stays internally coherent, which is what makes it a usable training example.</p>
<pre><code class="language-python">from pypdf import PdfReader
import io

reader = PdfReader(io.BytesIO(response.content))

def chunk_by_page(reader, max_chars=6000):
    chunks = []
    for page in reader.pages:
        text = page.extract_text()
        if not text or not text.strip():
            continue  # skip blank or image-only pages
        if len(text) &lt;= max_chars:
            chunks.append(text)
        else:
            # only split within a page when the page itself exceeds the limit
            for i in range(0, len(text), max_chars):
                chunks.append(text[i:i + max_chars])
    return chunks

chunks = chunk_by_page(reader)
print(f"Produced {len(chunks)} chunks from {len(reader.pages)} pages")
</code></pre>
<h2>Provenance tracking</h2>
<p>Every row in an authoritative seed needs a traceable source. A metadata record per document keeps the dataset auditable, covers legal requirements for commercial use, and makes it possible to re-retrieve and re-verify specific rows later.</p>
<pre><code class="language-python">from datetime import date

# Fetch the license from the landing page using Zenrows CSS extractor
license_params = {
    "url": landing_page_url,
    "apikey": apikey,
    "js_render": "true",
    "css_extractor": '{"license_url": "a[href*=\\"creativecommons.org\\"] @href"}',
}
license_resp = requests.get("https://api.zenrows.com/v1/", params=license_params)
license_data = license_resp.json()

source = {
    "url": pdf_url,
    "retrieved": date.today().isoformat(),
    "license": license_data.get("license_url", "unknown"),
}
</code></pre>
<h2>Batch collection with rate limit management</h2>
<p>Match concurrency to your plan limit. Spreading requests across workers with a short pause keeps the pipeline inside your quota without throttling from the target side.</p>
<pre><code class="language-python">import time
from concurrent.futures import ThreadPoolExecutor

MAX_CONCURRENT = 50  # Verify against your Zenrows plan

def fetch(url):
    params = {
        "url": url,
        "apikey": apikey,
        "response_type": "markdown",
        "mode": "auto",
        "wait": 3000,  # Wait for dynamic content to settle before returning
    }
    response = requests.get("https://api.zenrows.com/v1/", params=params)
    time.sleep(0.5)  # Pace requests within each worker thread

    if response.status_code != 200:
        return {"url": url, "status": response.status_code, "content": None}
    return {"url": url, "status": response.status_code, "content": response.text}

with ThreadPoolExecutor(max_workers=MAX_CONCURRENT) as executor:
    results = list(executor.map(fetch, urls))
</code></pre>
<p>For larger runs, <a href="https://docs.zenrows.com/batch/introduction">Zenrows Batch</a> manages concurrency, retries, and scheduling across large URL lists without a local orchestration layer.</p>
<h2>Three fine-tuning data formats</h2>
<p>The right format depends on the training method, not on what is easiest to produce.</p>
<h3>Instruction-output pairs</h3>
<p>Standard for supervised fine-tuning. Each example pairs the page Markdown (input) with the correct structured extraction (output). The model learns what to extract and how to format it.</p>
<pre><code class="language-python">import json

def to_training_example(instruction, markdown_content, extracted_json):
    return {
        "messages": [
            {"role": "system", "content": instruction},
            # The Markdown you pulled from the protected source
            {"role": "user", "content": markdown_content},
            # The verified structured output for this input
            {"role": "assistant", "content": json.dumps(extracted_json)},
        ]
    }

instruction = "Extract the product name, price, and availability as JSON."

with open("training_data.jsonl", "w") as f:
    for markdown_content, extracted_json in seed_examples:
        example = to_training_example(instruction, markdown_content, extracted_json)
        f.write(json.dumps(example) + "\n")  # One JSON object per line — JSONL format
</code></pre>
<h3>Preference pairs</h3>
<p>Used in DPO and RLHF. Two candidate outputs for the same input, one marked preferred. Correct when the model already produces reasonable outputs and the goal is distributional refinement rather than teaching the task from scratch.</p>
<pre><code class="language-python">def to_preference_pair(prompt, chosen, rejected):
    return {
        "prompt": prompt,
        "chosen": chosen,   # The preferred output
        "rejected": rejected, # The not-preferred output to the same prompt
    }
</code></pre>
<h3>Domain text continuation</h3>
<p>Raw domain text with no input-output structure. Useful for domain vocabulary and style familiarization, weak for extraction tasks because the model is never shown what should be extracted.</p>
<pre><code class="language-python">def to_continuation_example(markdown_content):
    # One field — the model learns by predicting what comes next
    return {"text": markdown_content}
</code></pre>
<p>For extraction tasks, instruction-output pairs are the correct choice. Continuation is a pretraining-style objective applied at fine-tuning scale; it improves fluency, not accuracy.</p>
<h2>Seed validation</h2>
<p>A row that fails any of these three checks does not enter the training set.</p>
<pre><code class="language-python">import hashlib
from jsonschema import validate, ValidationError

schema = {
    "type": "object",
    "properties": {
        "name": {"type": "string", "minLength": 1},
        "price": {"type": "string", "minLength": 1},
    },
    "required": ["name", "price"],
}

seen = set()

def keep(markdown, extracted):
    # Length: pages that rendered to almost nothing carry no useful signal
    if len(markdown.strip()) &lt; 200:
        return False
    try:
        # Schema: catches wrong types, empty values, missing required fields
        validate(instance=extracted, schema=schema)
    except ValidationError:
        return False
    # Deduplication: normalise whitespace before hashing to catch formatting-only duplicates
    h = hashlib.sha256(" ".join(markdown.lower().split()).encode()).hexdigest()
    if h in seen:
        return False
    seen.add(h)
    return True

clean_examples = [(url, md, js) for url, md, js in seed_examples if keep(md, js)]
</code></pre>
<h2>Measuring whether the seed held</h2>
<p>Hold out 10 to 20 percent of the real seed before any synthetic expansion and keep those examples permanently out of training and out of the synthetic generator.</p>
<pre><code class="language-python">import random

random.seed(42)       # Reproducible split across runs
random.shuffle(seed_examples)

split = int(len(seed_examples) * 0.85)
train_examples = seed_examples[:split]
holdout_examples = seed_examples[split:]  # Never enters training
</code></pre>
<p>After fine-tuning, run the model against the holdout and record task accuracy. Record the score after each retrain. A stable or improving score means the seed is anchoring correctly. A declining score is an early signal of drift before it compounds across cycles.</p>
<h2>What to build next</h2>
<ul>
<li><p><a href="https://www.zenrows.com/blog/extract-web-data-for-ai-training">How to extract web data for AI training</a>: broader training data collection patterns</p>
</li>
<li><p><a href="https://docs.zenrows.com/batch/introduction">Zenrows Batch</a>: managed concurrency and retries for large-scale collection</p>
</li>
<li><p><a href="https://www.zenrows.com/mcp">Zenrows MCP server</a>: web access for agents using the same retrieval layer</p>
</li>
<li><p><a href="https://docs.zenrows.com/fetch/api-reference">Zenrows Fetch documentation</a>: full parameter reference for <code>mode</code>, <code>wait_for</code>, <code>css_extractor</code>, and PDF handling</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Build a web-aware agent with OpenAI Agents SDK and Zenrows]]></title><description><![CDATA[This article was originally published on the Zenrows blog. Read the original here: https://www.zenrows.com/blog/web-aware-agent-openai-agents-sdk-zenrows

The retrieval gap in OpenAI's built-in web se]]></description><link>https://zenrows.hashnode.dev/build-a-web-aware-agent-with-openai-agents-sdk-and-zenrows</link><guid isPermaLink="true">https://zenrows.hashnode.dev/build-a-web-aware-agent-with-openai-agents-sdk-and-zenrows</guid><category><![CDATA[openai]]></category><category><![CDATA[zenrows]]></category><category><![CDATA[OpenAI Agents SDK]]></category><category><![CDATA[webscraping ]]></category><category><![CDATA[web scraping]]></category><category><![CDATA[Web Data Extraction]]></category><dc:creator><![CDATA[Praise James]]></dc:creator><pubDate>Mon, 14 Sep 2026 11:53:25 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a54cb5b6cccea2a6bc94bc9/5bb2876e-b669-4e01-baf4-013911a8614b.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>This article was originally published on the Zenrows blog. Read the original here: <a href="https://www.zenrows.com/blog/web-aware-agent-openai-agents-sdk-zenrows">https://www.zenrows.com/blog/web-aware-agent-openai-agents-sdk-zenrows</a></em></p>
<hr />
<h2>The retrieval gap in OpenAI's built-in web search</h2>
<p>The OpenAI Agents SDK ships <code>WebSearchTool</code> as a hosted tool that handles discovery well. It falls short in two structural ways, both of which matter for production agents.</p>
<p>First, it returns indexed snapshots, not live pages. When a site allows crawling, the built-in search returns a snippet of OpenAI's cached copy. For anything where freshness matters: a price, a stock level, a rate, that copy lags the live page by however long the refresh cycle takes.</p>
<p>Second, JavaScript-rendered content isn't in the index at all. Many pages send an HTML shell and populate prices, inventory, and structured data via JavaScript at runtime. The crawler stores the shell. The data you want was never captured, and crawling more often doesn't change that.</p>
<p>The architectural response is not to replace built-in search but to add a second tool alongside it, one that retrieves the full, live contents of a specific URL when discovery alone isn't enough. This guide shows how to register <a href="https://www.zenrows.com/products/fetch">Zenrows Fetch</a> as a <code>@function_tool</code> in the same agent, so the model selects between them each turn from their descriptions alone.</p>
<p>All the code is on <a href="https://github.com/ZenRows/web-aware-agent-openai-agents-sdk-zenrows">GitHub</a>.</p>
<h2>Architecture overview</h2>
<p>The agent holds two tools in one <code>tools</code> list. The model reads each tool's docstring every turn and selects whichever one fits the task.</p>
<p><code>WebSearchTool</code> runs on OpenAI's infrastructure. It takes a query, not a URL, and returns indexed content from public pages. It covers open-ended discovery, finding relevant documentation, and answering questions from public indexed content.</p>
<p><code>fetch_page_content</code> is a Python function decorated with <code>@function_tool</code>. It takes a specific URL, calls the Zenrows Fetch API with JavaScript rendering and adaptive proxy handling enabled, and returns the current page content as Markdown. It covers the cases built-in search can't reach: known URLs, protected pages, and JavaScript-rendered content.</p>
<p>The routing between them requires no conditional logic and no orchestration code on your side. It lives in the docstrings.</p>
<h2>Prerequisites</h2>
<ul>
<li><p>Python 3.9 or later</p>
</li>
<li><p>An OpenAI API key from your <a href="https://platform.openai.com/api-keys">OpenAI developer dashboard</a></p>
</li>
<li><p>A Zenrows API key from your <a href="https://app.zenrows.com/register">Zenrows dashboard</a></p>
</li>
</ul>
<p>Install dependencies:</p>
<pre><code class="language-bash">python3 -m pip install openai-agents python-dotenv
</code></pre>
<p>Add both keys to a <code>.env</code> file:</p>
<pre><code class="language-bash">OPENAI_API_KEY=your_openai_api_key_here
ZENROWS_API_KEY=your_zenrows_api_key_here
</code></pre>
<p>Add <code>.env</code> to <code>.gitignore</code>. Then load in your script:</p>
<pre><code class="language-python">from dotenv import load_dotenv

load_dotenv()
</code></pre>
<h2>Set up a baseline agent with the built-in search</h2>
<p><code>WebSearchTool</code> is a hosted tool that runs on OpenAI's infrastructure through the Responses API. Enabling it requires one line.</p>
<pre><code class="language-python">import asyncio
from dotenv import load_dotenv
from agents import Agent, Runner, WebSearchTool

load_dotenv()

agent = Agent(
    name="Product Researcher",
    instructions=(
        "You research products and web pages. "
        "Report exactly what you find, and state plainly when "
        "specific information is missing from your results."
    ),
    tools=[WebSearchTool()],
)

async def main():
    result = await Runner.run(
        agent,
        "Which laptops did Apple release most recently, and where can I buy them?",
    )
    print(result.final_output)

asyncio.run(main())
</code></pre>
<p>On broad discovery queries, this works. The problem appears on live or protected targets.</p>
<h2>Where the built-in search stops</h2>
<p>Asking the agent to fetch a live price from a JavaScript-heavy retailer illustrates both gaps:</p>
<pre><code class="language-python"># This demonstrates the retrieval gap on a JavaScript-rendered page
result = await Runner.run(
    agent,
    "What is the current price and stock status of "
    "https://www.amazon.com/dp/B0GR1JKMBV/ref=fs_a_mbt2_us1?th=1",
)
print(result.final_output)
</code></pre>
<p>The agent finds the page, returns the product title, and then states clearly what it couldn't access:</p>
<pre><code class="language-text">I visited the Amazon product page for ASIN B0GR1JKMBV...
The page displays: "To see product details, add this item to your cart."
Therefore I could not retrieve the current price or availability.
</code></pre>
<p>No error. The data simply wasn't in the index. This is the structural gap Zenrows fills.</p>
<h2>Register Zenrows Fetch as a @function_tool</h2>
<pre><code class="language-python">import os
import requests
from agents import function_tool

@function_tool
def fetch_page_content(url: str) -&gt; str:
    """Fetch the full, current content of a specific web page as Markdown.

    Use this when you need the complete content of a known URL rather than
    a search result summary, such as live prices, stock levels, or data
    that loads via JavaScript after the page opens.

    Args:
        url: The full URL of the page to retrieve.
    """
    response = requests.get(
        "https://api.zenrows.com/v1/",
        params={
            "url": url,
            "apikey": os.getenv("ZENROWS_API_KEY"),
            # mode=auto activates Adaptive Stealth Mode:
            # JavaScript rendering and premium proxy escalation
            # are enabled only when the target site requires them.
            # This keeps costs proportional to what each request needs.
            "mode": "auto",
            # response_type=markdown strips HTML markup
            # so the model reads clean text rather than raw HTML.
            "response_type": "markdown",
        },
        timeout=90,
    )

    # Return the error as a string so the agent can react to it
    # rather than receiving an unhandled exception.
    if response.status_code != 200:
        return f"Zenrows returned {response.status_code} for {url}"

    return response.text
</code></pre>
<h3>Why this approach</h3>
<p>The <code>@function_tool</code> decorator builds the tool schema from the function signature and type hints automatically. You don't write a JSON schema. The model uses the schema to construct valid calls.</p>
<p><code>mode=auto</code> is the important parameter choice. It activates <a href="https://docs.zenrows.com/fetch/features/adaptive-stealth-mode">Adaptive Stealth Mode</a>, which evaluates each target individually and enables JavaScript rendering and premium proxies only when the site requires them. An always-on premium proxy would work, but <code>mode=auto</code> keeps cost proportional to what each request actually needs.</p>
<p>The docstring is the routing mechanism. The model reads it every turn to decide whether the tool applies. The phrase "rather than a search result summary" is what distinguishes this tool from <code>WebSearchTool</code> in the model's decision-making. Without that phrase, the model treats both tools as interchangeable for any web-related task.</p>
<h2>Run both tools in a single agent loop</h2>
<pre><code class="language-python">import asyncio
import os
import requests
from dotenv import load_dotenv
from agents import Agent, Runner, WebSearchTool, function_tool

load_dotenv()

@function_tool
def fetch_page_content(url: str) -&gt; str:
    """Fetch the full, current content of a specific web page as Markdown.

    Use this when you need the complete content of a known URL rather than
    a search result summary, such as live prices, stock levels, or data
    that loads via JavaScript after the page opens.

    Args:
        url: The full URL of the page to retrieve.
    """
    response = requests.get(
        "https://api.zenrows.com/v1/",
        params={
            "url": url,
            "apikey": os.getenv("ZENROWS_API_KEY"),
            "mode": "auto",
            "response_type": "markdown",
        },
        timeout=90,
    )

    if response.status_code != 200:
        return f"Zenrows returned {response.status_code} for {url}"

    return response.text

# Both tools are registered in the same list.
# The model selects between them from their descriptions each turn.
agent = Agent(
    name="Web-Aware Researcher",
    instructions=(
        "You research products on the web. "
        "Use web search to find the product page URL. "
        "Then call fetch_page_content on that URL and report the price "
        "and stock status from the fetched page content. "
        "State which tool each figure came from."
    ),
    tools=[WebSearchTool(), fetch_page_content],
)

def print_trace(items):
    # Inspect result.new_items to confirm which tools the agent called
    for item in items:
        raw = getattr(item, "raw_item", None)
        name = getattr(raw, "name", None) or getattr(raw, "type", "")
        print(f"[{item.type}] {name}")

        if item.type == "tool_call_item" and name == "fetch_page_content":
            print("  args:", getattr(raw, "arguments", ""))

        if item.type == "tool_call_output_item":
            out = str(getattr(item, "output", ""))
            print(f"  output: {len(out)} chars")
            print(f"  body: {out[:500]}")

async def main():
    result = await Runner.run(
        agent,
        "Find the PriceOye page for the iPhone 17 Pro "
        "and report its current price and stock status.",
    )

    print_trace(result.new_items)
    print("\n---\n")
    print(result.final_output)

asyncio.run(main())
</code></pre>
<p>The trace shows the two-step pattern. Turn one is a <code>web_search_call</code>, discovery with no URL specified. Turn two calls <code>fetch_page_content</code> with the URL it found:</p>
<pre><code class="language-text">[tool_call_item] web_search_call
[message_output_item] message
[tool_call_item] fetch_page_content
  args: {"url":"https://priceoye.pk/mobiles/apple/apple-iphone-17-pro"}
[tool_call_output_item]

---

Here are the details for the iPhone 17 Pro from its PriceOye product page:

Current Price: Rs 471,999
Stock Status: Only 1 left in stock

Price and stock status are directly extracted from the fetched page content
(functions.fetch_page_content tool).
</code></pre>
<p>The routing decision the model made: <code>web_search_call</code> for discovery and <code>fetch_page_content</code> for retrieval matches the docstrings almost exactly. There's no conditional logic anywhere in the agent definition.</p>
<h2>When to use each tool</h2>
<p><code>WebSearchTool</code> covers discovery: finding relevant pages, answering questions from indexed public content, identifying URLs for follow-up retrieval.</p>
<p>Zenrows covers retrieval: full contents of a specific URL, protected pages, JavaScript-rendered content, anything where freshness matters.</p>
<p>If you need specific fields rather than full-page Markdown, <a href="https://docs.zenrows.com/extract/introduction">Zenrows Extract</a> returns structured data so the model never has to parse Markdown for a price. For recurring high-volume retrieval across a catalog, <a href="https://www.zenrows.com/products/batch">Zenrows Batch</a> runs those jobs without you managing concurrency.</p>
<p>The same <code>@function_tool</code> wrapper carries over to other agent frameworks. The guide on <a href="https://www.zenrows.com/blog/web-research-multi-agent-ag2-zenrows">building a web research multi-agent system with AG2 and Zenrows</a> shows how the pattern extends to multi-agent setups. <a href="https://www.zenrows.com/blog/zenrows-smolagents">Zenrows smolagents</a> uses the same function under a <code>@tool</code> decorator.</p>
<h2>Conclusion</h2>
<p>The agent now reaches the web two ways. <code>WebSearchTool</code> handles discovery. Zenrows reads the full live page when you have a URL and need the current contents, including JavaScript-rendered data and anti-bot-defended targets. The model routes between them from the tool descriptions alone, with no orchestration code.</p>
<h2>What to build next</h2>
<ul>
<li><p><a href="https://docs.zenrows.com/mcp/overview">Zenrows MCP server</a>: ready-made tools via the Responses API <code>mcp</code> tool type, with no Python wrapper to maintain</p>
</li>
<li><p><a href="https://docs.zenrows.com/integrations/openai-agents-sdk">OpenAI Agents SDK integration guide</a>: full reference for MCP and <code>@function_tool</code> patterns in the SDK</p>
</li>
<li><p><a href="https://www.zenrows.com/products/batch">Zenrows Batch</a>: scheduled retrieval across many URLs without managing concurrency</p>
</li>
<li><p><a href="https://docs.zenrows.com/extract/introduction">Zenrows Extract</a>: structured field extraction so the model doesn't parse raw Markdown</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[How we scraped the 2026 FIFA World Cup data and what shocked us]]></title><description><![CDATA[This article was originally published on the Zenrows blog. Read the original here: https://www.zenrows.com/blog/scrape-2026-fifa-world-cup-data-python

Architecture overview
Building a reliable sports]]></description><link>https://zenrows.hashnode.dev/how-we-scraped-the-2026-fifa-world-cup-data-and-what-shocked-us</link><guid isPermaLink="true">https://zenrows.hashnode.dev/how-we-scraped-the-2026-fifa-world-cup-data-and-what-shocked-us</guid><category><![CDATA[fifa]]></category><category><![CDATA[webscraping ]]></category><category><![CDATA[#FIFA2026]]></category><category><![CDATA[zenrows]]></category><category><![CDATA[Python]]></category><category><![CDATA[data]]></category><category><![CDATA[data analysis]]></category><category><![CDATA[analysis]]></category><dc:creator><![CDATA[Praise James]]></dc:creator><pubDate>Mon, 14 Sep 2026 11:49:26 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a54cb5b6cccea2a6bc94bc9/d95e595c-1af3-4d82-9a7c-4ed9a7c5f902.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p><em>This article was originally published on the Zenrows blog. Read the original here: <a href="https://www.zenrows.com/blog/scrape-2026-fifa-world-cup-data-python">https://www.zenrows.com/blog/scrape-2026-fifa-world-cup-data-python</a></em></p>
</blockquote>
<h2>Architecture overview</h2>
<p>Building a reliable sports data pipeline means designing around the access pattern of each source. Every source behaves differently. The 2026 World Cup pipeline pulled from Sofascore for player statistics, FIFA for team metrics, and Wikipedia for tournament metadata. Each delivered its data through a different mechanism, and each needed a different extraction strategy.</p>
<p>The three access patterns broke down like this.</p>
<ul>
<li><p>A JavaScript-rendered single-page application exposing a JSON endpoint (Sofascore)</p>
</li>
<li><p>A backend API protected by a per-session JWT issued during the browser session (FIFA)</p>
</li>
<li><p>Server-rendered HTML with tabular data (Wikipedia)</p>
</li>
</ul>
<p>Standard <code>requests</code> handles only the third reliably. The first returns an empty application shell. The second returns a 401 without the correct session token. This guide covers how we handled all three, how we normalized the outputs into a single dataset, and what the data revealed.</p>
<p>Full source: <a href="https://github.com/ZenRows/scraping-world-cup-data">GitHub repository</a></p>
<h2>Prerequisites</h2>
<ul>
<li><p>Python 3.10 or later</p>
</li>
<li><p><a href="https://app.zenrows.com/register">Zenrows account</a></p>
</li>
<li><p><code>playwright</code>, <code>requests</code>, <code>beautifulsoup4</code>, <code>pandas</code>, <code>python-dotenv</code></p>
</li>
</ul>
<pre><code class="language-bash">pip install requests playwright beautifulsoup4 pandas python-dotenv
playwright install chromium
</code></pre>
<h2>Retrieving Sofascore data from a JavaScript endpoint</h2>
<h3>Why this approach</h3>
<p>Sofascore is a single-page application. The browser loads a JavaScript bundle, which calls an internal statistics API and renders the result into the DOM. A bare HTTP request retrieves the application shell with no data inside it.</p>
<p>Zenrows Fetch with <code>mode=auto</code> intercepts the API call through a rendered browser context and returns the JSON response directly. Because the target is a JSON endpoint rather than an HTML page, no parsing step is needed on our side.</p>
<pre><code class="language-python">import os
import time
from urllib.parse import urlencode

import requests

ZENROWS_API_KEY = os.getenv("ZENROWS_API_KEY")

class SofascoreClient:
    BASE_URL = "https://www.sofascore.com/api/v1"

    def _get(self, endpoint: str, params: dict | None = None):
        base_url = f"{self.BASE_URL}/{endpoint.lstrip('/')}"
        target_url = f"{base_url}?{urlencode(params)}" if params else base_url

        retries = 3
        for attempt in range(retries):
            try:
                response = requests.get(
                    "https://api.zenrows.com/v1/",
                    params={
                        "apikey": ZENROWS_API_KEY,
                        "url": target_url,
                        # Adaptive Stealth Mode: picks JS rendering or proxy escalation per request
                        "mode": "auto",
                    },
                    timeout=120,
                )
                response.raise_for_status()
                return response.json()
            except (
                requests.exceptions.ReadTimeout,
                requests.exceptions.ConnectionError,
                requests.exceptions.HTTPError,
            ) as e:
                print(f"Attempt {attempt + 1}/{retries} failed: {e}")
                if attempt == retries - 1:
                    raise
                wait = 2 ** attempt  # exponential backoff
                time.sleep(wait)

    def fetch_statistics(
        self, group, order, page=1, limit=20, accumulation="total"
    ):
        endpoint = f"unique-tournament/{TOURNAMENT_ID}/season/{SEASON_ID}/statistics"
        params = {
            "group": group,
            "order": order,
            "page": page,
            "limit": limit,
            "accumulation": accumulation,
        }
        return self._get(endpoint, params=params)
</code></pre>
<h2>Retrieving FIFA data from an authenticated API</h2>
<h3>Why this approach</h3>
<p>FIFA's statistics page calls a backend API with an authorization header carrying a JWT bearer token. The token is issued per browsing session. Every visitor gets a temporary credential and the front end attaches it to all subsequent API requests. Querying the API endpoint directly without that token returns a 401.</p>
<p>Fetch alone cannot acquire a session token because that requires executing the full browser session authentication flow. Zenrows Browser Sessions with Playwright follows the same flow a normal browser does, maintains the token across requests, and lets us intercept API responses as the page navigates through each statistics tab.</p>
<pre><code class="language-python">import os
from playwright.async_api import async_playwright

class FIFAClient:
    def __init__(self):
        self.api_key = os.getenv("ZENROWS_API_KEY")
        # Connect to a Zenrows-managed browser over CDP
        self.connection_url = f"wss://browser.zenrows.com?apikey={self.api_key}"
        self.responses = {}

    async def _handle_response(self, response):
        # Filter to only the FIFA statistics API responses
        if "gameday-prod.fifa.mangodev.co.uk" not in response.url:
            return
        # Store the JSON response keyed by URL for later normalization
        self.responses[response.url] = await response.json()

    async def fetch_all(self):
        async with async_playwright() as p:
            browser = await p.chromium.connect_over_cdp(self.connection_url)
            context = browser.contexts[0]
            page = await context.new_page()

            # Intercept all network responses before navigating
            page.on("response", self._handle_response)
            await page.goto(self.URL)
            await self._remove_cookie_banner(page)

            # Click through each statistics tab to trigger the API calls
            for tab in self.TABS:
                await self._click_tab(page, tab)

            self._save_files()
</code></pre>
<h2>Retrieving Wikipedia tournament metadata</h2>
<h3>Why this approach</h3>
<p>Wikipedia is server-rendered. All content is in the HTML the server returns. Fetch retrieved it without additional capabilities. BeautifulSoup located the correct table by checking for the presence of specific column headers rather than relying on a fixed table index, which breaks when Wikipedia's page structure changes.</p>
<pre><code class="language-python">import re
from bs4 import BeautifulSoup

def normalize(self):
    with open(self.INPUT_FILE, encoding="utf-8") as f:
        soup = BeautifulSoup(f, "html.parser")

    target_table = None

    # Locate by headers rather than by table index — more resilient to page edits
    for table in soup.find_all("table", class_="wikitable"):
        headers = [th.get_text(" ", strip=True) for th in table.find_all("th")]
        if (
            "Year" in headers
            and "Host" in headers
            and "Cities" in headers
            and "Stadiums" in headers
        ):
            target_table = table
            break

    if target_table is None:
        raise ValueError("Could not locate the World Cup stadium table.")

    records = []
    for row in target_table.find("tbody").find_all("tr")[1:]:
        cells = row.find_all(["th", "td"])
        if len(cells) &lt; 4:
            continue
        year_match = re.search(r"\d{4}", cells[0].get_text())
        if not year_match:
            continue
        records.append({
            "year": int(year_match.group()),
            "hosts": [t.strip() for t in cells[1].stripped_strings if t.strip()],
            "cities": int(re.search(r"\d+", cells[2].get_text()).group()),
            "stadiums": int(re.search(r"\d+", cells[3].get_text()).group()),
        })
</code></pre>
<h2>Normalization</h2>
<p>Each source's raw output went through its own normalization layer before merging into a single dataset. Sofascore returns the same player across multiple statistic groups, so we deduplicated by player ID rather than by name.</p>
<pre><code class="language-python">import json

class SofascoreNormalizer:
    def normalize_player(self, record):
        player = record.get("player", {})
        team = record.get("team", {})
        # Flatten stats into a single dict, excluding the top-level player and team keys
        stats = {k: v for k, v in record.items() if k not in {"player", "team", "rating"}}
        return {
            "player_id": player.get("id"),
            "player": player.get("name"),
            "player_slug": player.get("slug"),
            "team_id": team.get("id"),
            "team": team.get("name"),
            "rating": record.get("rating"),
            "stats": stats,
        }

    def normalize_dataset(self, dataset):
        with open(f"data/raw/sofascore/{dataset}.json") as f:
            data = json.load(f)

        players = {}
        for record in data:
            normalized = self.normalize_player(record)
            player_id = normalized["player_id"]
            if player_id is None:
                continue
            # Last write wins per player ID — keeps the most recently processed stat group
            players[player_id] = normalized

        return list(players.values())
</code></pre>
<p>We loaded each normalized dataset into a Pandas DataFrame for a consistent interface across sources before calculating derived metrics and producing charts.</p>
<h2>What the data revealed</h2>
<p>Fifteen findings came out of the combined dataset. Several challenged what we expected going in. The ones that stood out most were these.</p>
<ul>
<li><p>Ousmane Dembélé scored six goals from just over two expected goals, close to three times his xG, the most efficient conversion among the top strikers</p>
</li>
<li><p>The Golden Glove went to Unai Simón, who doesn't appear in the top ten goalkeepers by rating in our dataset. Orlando Gill, whose Paraguay went out in the Round of 16, tops our list and led all goalkeepers in saves</p>
</li>
<li><p>Jude Bellingham, Rodri, and Aurélien Tchouaméni ranked above all centre-backs in combined defensive actions</p>
</li>
<li><p>Japan recorded the highest shot conversion rate in the tournament while taking the lowest share of shots from inside the box. Only 50% of their shots came from inside the box compared to over 70% for most other top-converting teams</p>
</li>
<li><p>Michael Olise set a new World Cup record with seven assists and led the tournament in big chances created while holding one of the highest passing accuracies</p>
</li>
<li><p>France, England, and Argentina applied the most defensive pressure and had among the slowest ball recovery times. Türkiye applied far fewer pressures and recovered the ball fastest</p>
</li>
<li><p>Argentina reached the final with the most bookings in the tournament and no VAR interventions against them</p>
</li>
<li><p>Spain recorded the most defensive line breaks while holding around 90% passing accuracy. Most teams trade accuracy for line-breaking volume. Spain didn't.</p>
</li>
<li><p>The 2026 tournament was hosted by three countries and used 16 stadiums, fewer than Spain used alone in 1982</p>
</li>
</ul>
<p>The full findings with visualizations are in the <a href="https://www.zenrows.com/blog/scrape-2026-fifa-world-cup-data-python">original article</a>.</p>
<h2>What to build next</h2>
<ul>
<li><p><a href="https://docs.zenrows.com/browser-sessions/get-started/playwright">Zenrows Browser Sessions</a> for authenticated browser workflows</p>
</li>
<li><p><a href="https://www.zenrows.com/products/batch">Zenrows Batch</a> for managed concurrency across large URL lists</p>
</li>
<li><p><a href="https://www.zenrows.com/blog/web-data-llm-fine-tuning">Web data for LLM fine-tuning</a> for taking scraped data into training pipelines</p>
</li>
<li><p><a href="https://docs.zenrows.com/fetch/api-reference">Zenrows Fetch documentation</a> covering <code>mode</code>, <code>wait_for</code>, and JSON response handling</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Build a web research multi-agent system with AG2 and Zenrows]]></title><description><![CDATA[This article was originally published on the Zenrows blog. Read the original here: https://www.zenrows.com/blog/web-research-multi-agent-ag2-zenrows

Why the fetch layer determines everything downstre]]></description><link>https://zenrows.hashnode.dev/build-a-web-research-multi-agent-system-with-ag2-and-zenrows</link><guid isPermaLink="true">https://zenrows.hashnode.dev/build-a-web-research-multi-agent-system-with-ag2-and-zenrows</guid><category><![CDATA[ag2]]></category><category><![CDATA[multi-agent systems]]></category><category><![CDATA[zenrows]]></category><category><![CDATA[AI]]></category><category><![CDATA[ai agents]]></category><category><![CDATA[AI research assistant]]></category><category><![CDATA[AutoGen]]></category><dc:creator><![CDATA[Praise James]]></dc:creator><pubDate>Mon, 14 Sep 2026 11:43:22 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a54cb5b6cccea2a6bc94bc9/7a548064-ad00-4a99-809a-073e2d8c1327.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>This article was originally published on the Zenrows blog. Read the original here: <a href="https://www.zenrows.com/blog/web-research-multi-agent-ag2-zenrows">https://www.zenrows.com/blog/web-research-multi-agent-ag2-zenrows</a></em></p>
<hr />
<h2>Why the fetch layer determines everything downstream</h2>
<p>AG2's group chat architecture is built around a shared history. Every message, including every tool response, enters a shared context window that all agents read on their turn. This is what makes multi-agent pipelines powerful: each agent builds on what the agents before it produced.</p>
<p>It's also what makes a broken fetch expensive. When the researcher can't reach a URL, that failure enters the shared history as a message. The analyst reads it, has nothing to work with, and says so. The critic reads both, validates that neither produced anything, and terminates. Every agent in the pipeline spent tokens on content that never existed.</p>
<p>The fix is architectural. Register a fetch tool at the researcher level that actually works, one that handles JavaScript-rendered and anti-bot-protected pages, so clean content enters the history from the first turn. This guide does that with <a href="https://www.zenrows.com/products/fetch">Zenrows Fetch</a>, compares token usage across both configurations, and explains the cost dynamics in a multi-agent context.</p>
<p><a href="https://www.ag2.ai/">AG2</a> is the actively maintained community fork of Microsoft's AutoGen, with native MCP client support added in v0.12. This guide uses the OpenAI client.</p>
<p>All the code is on <a href="https://github.com/ZenRows/ag2-zenrows-research-pipeline">GitHub</a>.</p>
<h2>Architecture overview</h2>
<p>The pipeline has four agents in one group chat.</p>
<p><code>user_proxy</code> is the execution agent. It runs any registered function the LLM requests and terminates the chat when an agent message contains <code>TERMINATE</code>.</p>
<p><code>researcher</code> holds the fetch tool. When given a URL, it calls <code>fetch_page_content</code>, receives clean Markdown from Zenrows, and passes a summary to the analyst.</p>
<p><code>analyst</code> reads the researcher's summary and extracts structured fields — product names, prices, or whatever the task specifies. It doesn't call any tools.</p>
<p><code>critic</code> validates the analyst's output against the researcher's original content, flags gaps or inconsistencies, and terminates the pipeline when the output checks out.</p>
<p>The key design decision is where the tool is registered. <code>register_for_llm(researcher)</code> exposes the schema to the researcher so the model knows to call it. <code>register_for_execution(user_proxy)</code> tells AG2 which agent runs the function — in a group chat, that's always the <code>UserProxyAgent</code>. This split is required; registering both on the same agent doesn't work.</p>
<h2>Prerequisites</h2>
<ul>
<li><p>Python 3.10 or later</p>
</li>
<li><p>AG2 v0.12.2</p>
</li>
<li><p>An <a href="https://platform.openai.com/home">OpenAI API key</a></p>
</li>
<li><p>A <a href="https://app.zenrows.com/register">Zenrows API key</a></p>
</li>
</ul>
<p>Install AG2 with its OpenAI client:</p>
<pre><code class="language-bash">pip install "ag2[openai]==0.12.2"
</code></pre>
<p>Two things before you run anything. The package name and the import name differ: install as <code>ag2</code>, import as <code>autogen</code>. Using <code>import ag2</code> raises a <code>ModuleNotFoundError</code> in v0.12.2; AG2 is deprecating the classic framework at v1.0, at which point <code>import ag2</code> becomes correct. AG2 also doesn't bundle the OpenAI client, which is why <code>[openai]</code> is required.</p>
<p>Load keys from <code>.env</code>:</p>
<pre><code class="language-python">import os
from dotenv import load_dotenv

import autogen
from autogen import AssistantAgent, GroupChat, GroupChatManager, UserProxyAgent

load_dotenv()

llm_config = {
    "config_list": [
        {
            "model": "gpt-4o-mini",
            "api_key": os.getenv("OPENAI_API_KEY"),
        }
    ],
    # temperature 0 for deterministic output across runs
    "temperature": 0,
}
</code></pre>
<h2>Set up an AG2 group chat</h2>
<p>Define four agents, wire them into a group chat, and run a baseline to see the pipeline with no fetch tool:</p>
<pre><code class="language-python"># user_proxy executes tools and terminates on TERMINATE
user_proxy = UserProxyAgent(
    name="user_proxy",
    human_input_mode="NEVER",
    code_execution_config=False,
    is_termination_msg=lambda msg: "TERMINATE" in (msg.get("content") or ""),
)

# researcher fetches and summarizes — no tool registered yet
researcher = AssistantAgent(
    name="researcher",
    system_message=(
        "You are a research agent. When given a URL, fetch its web content and "
        "summarize what you find. Be factual and note anything that looks "
        "incomplete or missing from the page. Pass your summary to the analyst."
    ),
    llm_config=llm_config,
)

# analyst extracts structured fields from researcher output
analyst = AssistantAgent(
    name="analyst",
    system_message=(
        "You are a data analyst. Extract structured fields, such as plan names "
        "and prices, from the content the researcher provides. Present the "
        "result as a clean list. If the researcher's content has no usable "
        "data, say so explicitly instead of guessing."
    ),
    llm_config=llm_config,
)

# critic validates and terminates
critic = AssistantAgent(
    name="critic",
    system_message=(
        "You are a critic. Validate the analyst's structured output against "
        "the researcher's original content. Flag any gaps, missing fields, or "
        "inconsistencies. When the output checks out or no further progress is "
        "possible, say so and end your message with TERMINATE."
    ),
    llm_config=llm_config,
)

group_chat = GroupChat(
    agents=[user_proxy, researcher, analyst, critic],
    messages=[],
    max_round=6,
)

manager = GroupChatManager(groupchat=group_chat, llm_config=llm_config)

chat_result = user_proxy.initiate_chat(
    manager,
    message=(
        "Fetch the product listing from this page and extract the "
        "product name and price: https://www.walmart.com/ip/AirPods-Pro-3/17835006350"
    ),
)
</code></pre>
<p>For accurate token counts, call <code>autogen.gather_usage_summary()</code> on the full agent list. <code>chat_result.cost</code> always reports zero for group chats:</p>
<pre><code class="language-python"># gather_usage_summary is the only reliable source for group chat token counts
usage_summary = autogen.gather_usage_summary(
    [user_proxy, researcher, analyst, critic, manager]
)
print(usage_summary)
</code></pre>
<p>The baseline output shows the failure propagating:</p>
<pre><code class="language-text">researcher: I'm unable to access external URLs directly to fetch content.
analyst: I cannot access external URLs or fetch content from them.
critic: Both parties acknowledge the inability to access external URLs. TERMINATE

Total tokens: 990 | Cost: $0.000252
</code></pre>
<table>
<thead>
<tr>
<th>Run</th>
<th>Products extracted</th>
<th>Total tokens</th>
<th>Cost</th>
</tr>
</thead>
<tbody><tr>
<td>Baseline (no fetch tool)</td>
<td>No</td>
<td>990</td>
<td>$0.000252</td>
</tr>
</tbody></table>
<h2>Register Zenrows Fetch as a typed AG2 tool</h2>
<p>AG2 builds the tool schema from type hints. <code>Annotated[type, description]</code> on every input parameter is required. Without it, AG2 can't build the schema, and registration fails silently or raises a validation error.</p>
<pre><code class="language-python">import requests
from typing import Annotated
from autogen.tools import Tool

ZENROWS_API_KEY = os.getenv("ZENROWS_API_KEY")
ZENROWS_ENDPOINT = "https://api.zenrows.com/v1/"

def fetch_page_content(
    url: Annotated[str, "The target URL to fetch through Zenrows using adaptive stealth mode."],
) -&gt; str:
    """Fetch a URL through Zenrows Fetch and return clean Markdown."""
    params = {
        "url": url,
        "apikey": ZENROWS_API_KEY,
        # mode=auto activates Adaptive Stealth Mode:
        # Zenrows evaluates the target and enables JS rendering
        # or premium proxies only when the site requires them.
        # This keeps credit spend proportional to what each request needs.
        "mode": "auto",
        # response_type=markdown returns clean text rather than raw HTML,
        # keeping token count proportional to content rather than markup.
        "response_type": "markdown",
    }
    response = requests.get(ZENROWS_ENDPOINT, params=params, timeout=60)
    response.raise_for_status()
    return response.text
</code></pre>
<h3>Why this approach</h3>
<p><code>mode=auto</code> is <a href="https://docs.zenrows.com/fetch/features/adaptive-stealth-mode">Adaptive Stealth Mode</a>. Rather than always enabling JavaScript rendering and premium proxies, which adds cost on pages that don't need them. Zenrows evaluates each target individually. A basic static page costs 1 credit. JavaScript rendering costs 5, premium proxies cost 10, and a heavily protected site needing both costs 25. You're billed once, for the configuration that succeeds.</p>
<p><code>response_type=markdown</code> matters specifically in a multi-agent context. Raw HTML inflates the shared history, which inflates every subsequent agent turn that reads it. Markdown gives the model what it needs without the markup overhead.</p>
<p>Wrap the function and register it on the correct agents:</p>
<pre><code class="language-python">fetch_tool = Tool(
    name="fetch_page_content",
    description=(
        "Fetch a URL through Zenrows and return clean Markdown content from "
        "JavaScript-rendered and anti-bot-protected pages."
    ),
    func_or_tool=fetch_page_content,
)

# register_for_llm exposes the schema to the researcher
fetch_tool.register_for_llm(researcher)
# register_for_execution tells AG2 which agent runs the function
# — always user_proxy in a group chat
fetch_tool.register_for_execution(user_proxy)
</code></pre>
<p>Update the researcher's system message to name the tool:</p>
<pre><code class="language-python">researcher = AssistantAgent(
    name="researcher",
    system_message=(
        "You are a research agent. When given a URL, use the fetch_page_content "
        "tool to retrieve its web content, then summarize what you find. Be "
        "factual and note anything that looks incomplete or missing from the "
        "page. Pass your summary to the analyst."
    ),
    llm_config=llm_config,
)
</code></pre>
<h2>Run the pipeline and compare token spend</h2>
<p>With Zenrows registered, the researcher calls <code>fetch_page_content</code>. Zenrows retrieves the page through Adaptive Stealth Mode. Clean Markdown enters the shared history. The analyst extracts product data. The critic validates and terminates.</p>
<pre><code class="language-text">researcher: ***** Suggested tool call: fetch_page_content *****
Arguments: {"url":"https://www.walmart.com/ip/AirPods-Pro-3/17835006350"}
&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt; EXECUTING FUNCTION fetch_page_content...

[Markdown from Walmart page enters shared history]

analyst:
- Product Name: Apple AirPods Pro 3
- Price: $189.99 (was $249.00, you save $59.01)

critic: The analyst's output is consistent with the researcher's content.
No gaps or missing fields. TERMINATE

Total tokens: 7,236 | Cost: $0.001147
</code></pre>
<table>
<thead>
<tr>
<th>Run</th>
<th>Products extracted</th>
<th>Total tokens</th>
<th>Cost</th>
</tr>
</thead>
<tbody><tr>
<td>Baseline (no fetch tool)</td>
<td>No</td>
<td>990</td>
<td>$0.000252</td>
</tr>
<tr>
<td>Fetch with mode=auto</td>
<td>Yes</td>
<td>7,236</td>
<td>$0.001147</td>
</tr>
</tbody></table>
<p>The working run spends more tokens because the Markdown from a fully fetched page is far larger than the researcher's brief refusal in the baseline. That token spend is proportional to content. The baseline was cheaper because it retrieved nothing.</p>
<p>Both runs terminate in approximately the same number of turns, so the round count isn't the cost variable. What changes is whether there's real content in the history.</p>
<h2>Keeping costs predictable in production</h2>
<p>Content volume in the shared history is the primary cost driver, not the number of turns. Every agent turn re-reads and forwards the full history, so page size multiplies across the pipeline.</p>
<p><code>max_round</code> sets a ceiling on your worst case. Start conservative and raise it only when the pipeline consistently needs more turns to finish naturally. Every failed or empty fetch that enters the history pushes the round count higher without producing anything useful, so reliable retrieval is the most direct way to keep <code>max_round</code> from becoming your main cost control.</p>
<p>For pipelines that fetch the same URL more than once per session, caching the Zenrows response after the first call cuts both token spend and credit usage. For pipelines that research several URLs in one run, <a href="https://www.zenrows.com/products/batch">Zenrows Batch</a> takes the full list as one managed job and handles concurrency and retries.</p>
<h2>Conclusion</h2>
<p>AG2's shared history is the architectural fact that makes the fetch layer critical. A failed fetch doesn't stay isolated. It enters the history and every agent after reads and forwards it, spending tokens on turns that had nothing real to work with.</p>
<p>Registering Zenrows as a typed fetch tool with <code>mode=auto</code> addresses that at the source. Zenrows decides what each target needs, whether that's plain rendering, JavaScript execution, or premium proxies, without you configuring it per site. Clean Markdown enters the shared history on the first call, so the analyst and critic get real content and the pipeline terminates naturally.</p>
<h2>What to build next</h2>
<ul>
<li><p><a href="https://docs.zenrows.com/mcp/overview">Zenrows MCP server</a>: AG2 v0.12 has native MCP client support if you prefer ready-made tools over a registered function</p>
</li>
<li><p><a href="https://www.zenrows.com/blog/web-aware-agent-openai-agents-sdk-zenrows">OpenAI Agents SDK + Zenrows</a>: the same Zenrows fetch pattern in a single-agent loop with <code>@function_tool</code></p>
</li>
<li><p><a href="https://www.zenrows.com/blog/zenrows-smolagents">Zenrows smolagents guide</a>: the same wrapper under a <code>@tool</code> decorator in a different framework</p>
</li>
<li><p><a href="https://www.zenrows.com/products/batch">Zenrows Batch</a>: scheduled or high-volume retrieval across many URLs without managing fetch queues</p>
</li>
<li><p><a href="https://docs.zenrows.com/fetch/features/adaptive-stealth-mode#pricing">Adaptive Stealth Mode pricing table</a>: full breakdown of credit costs by configuration</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[How to scrape protected sites in Cursor with Zenrows MCP
]]></title><description><![CDATA[This article was originally published on the Zenrows blog. Read the original here: https://www.zenrows.com/blog/scrape-protected-sites-cursor

Why Cursor's built-in web access breaks on the pages that]]></description><link>https://zenrows.hashnode.dev/how-to-scrape-protected-sites-in-cursor-with-zenrows-mcp</link><guid isPermaLink="true">https://zenrows.hashnode.dev/how-to-scrape-protected-sites-in-cursor-with-zenrows-mcp</guid><category><![CDATA[zenrows]]></category><category><![CDATA[cursor]]></category><category><![CDATA[mcp]]></category><category><![CDATA[zenrows-mcp]]></category><category><![CDATA[ai agents]]></category><category><![CDATA[AI]]></category><category><![CDATA[web scraping]]></category><category><![CDATA[web scraping api]]></category><category><![CDATA[Web Data]]></category><category><![CDATA[Web Data Extraction]]></category><dc:creator><![CDATA[Praise James]]></dc:creator><pubDate>Mon, 14 Sep 2026 11:36:31 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a54cb5b6cccea2a6bc94bc9/52579757-6536-4a22-96e3-ef84a31c4734.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>This article was originally published on the Zenrows blog. Read the original here: <a href="https://www.zenrows.com/blog/scrape-protected-sites-cursor">https://www.zenrows.com/blog/scrape-protected-sites-cursor</a></em></p>
<hr />
<h2>Why Cursor's built-in web access breaks on the pages that matter most</h2>
<p>Cursor's built-in web access reads from cached or pre-rendered snapshots. That approach works on stable, public, text-heavy pages. It breaks in three specific situations that agent workflows hit constantly.</p>
<p><strong>JavaScript-rendered pages.</strong> Many pages ship an HTML shell and populate their actual content like product grids, pricing tables, and API responses via JavaScript at runtime. Cursor fetches the shell. The data was never in it.</p>
<p><strong>Bot-protected sites.</strong> Pages behind Cloudflare, DataDome, or similar anti-bot systems detect the request, return a challenge interstitial, and block the data behind it. No error is surfaced in the agent response. Your agent returns the interstitial, not the page.</p>
<p><strong>Recently updated content.</strong> Documentation, pricing, and API references can change faster than a cache refreshes. The snapshot Cursor reads may be hours or days old with nothing marking it stale.</p>
<p>The failure mode is the same in all three cases: your agent returns something that looks like a valid response, just not what the page actually contains. In an agent workflow, that silent wrong answer propagates downstream.</p>
<p>Zenrows MCP closes that gap at the configuration level. You add one JSON file, restart Cursor, and the agent routes web requests through Zenrows' web data infrastructure with a 99.93% success rate across supported targets, including JavaScript-rendered and anti-bot-protected pages.</p>
<h2>Architecture overview</h2>
<p>The Zenrows MCP server runs as a local process spawned by Cursor via <code>npx</code>. When the agent decides to fetch a URL, it calls the Zenrows scrape tool instead of Cursor's built-in browsing. The tool sends the request to Zenrows, which evaluates what the target page needs, whether that's a plain HTTP request, JavaScript rendering, premium proxy routing, or a combination, and returns clean Markdown, JSON, or HTML depending on what you ask for.</p>
<p>The <code>mcp.json</code> config is the only integration surface. No code changes or SDK setup. The agent prompt tells Cursor which tool to use.</p>
<h2>Prerequisites</h2>
<ul>
<li><p>Node.js v18 or later. Run <code>node --version</code> to confirm</p>
</li>
<li><p>A Zenrows API key from <a href="https://app.zenrows.com/register">app.zenrows.com</a></p>
</li>
<li><p>Cursor installed</p>
</li>
</ul>
<h2>Add Zenrows MCP to Cursor</h2>
<h3>Step 1: Create the config file</h3>
<p>The <code>mcp.json</code> path depends on your operating system:</p>
<table>
<thead>
<tr>
<th>OS</th>
<th>Path</th>
</tr>
</thead>
<tbody><tr>
<td>macOS / Linux</td>
<td><code>~/.cursor/mcp.json</code></td>
</tr>
<tr>
<td>Windows</td>
<td><code>%APPDATA%\Cursor\mcp.json</code></td>
</tr>
</tbody></table>
<p>On macOS and Linux, create the file in one command:</p>
<pre><code class="language-bash">mkdir -p ~/.cursor &amp;&amp; cat &gt; ~/.cursor/mcp.json &lt;&lt; 'EOF'
{
  "mcpServers": {
    "zenrows": {
      "command": "npx",
      "args": ["-y", "@zenrows/mcp"],
      "env": {
        "ZENROWS_API_KEY": "your_api_key_here"
      }
    }
  }
}
EOF
</code></pre>
<p>On Windows, open <code>%USERPROFILE%\.cursor\</code> in File Explorer, create <code>mcp.json</code>, and paste the same JSON.</p>
<h3>Step 2: Validate the JSON before opening Cursor</h3>
<pre><code class="language-bash"># A trailing comma or missing bracket stops the config loading silently.
# Run this before opening Cursor so syntax errors surface early.
node -e "JSON.parse(require('fs').readFileSync(require('os').homedir()+'/.cursor/mcp.json','utf8')); console.log('JSON valid')"
</code></pre>
<p><code>JSON valid</code> means the file parsed correctly. Any output other than that is a syntax error in the config.</p>
<h3>Step 3: Confirm the connection in Cursor</h3>
<p>Open Cursor → gear icon (bottom left) → search "MCP" → <strong>Tools &amp; MCPs → Home MCP Servers</strong>. The <code>zenrows</code> entry should show a green status dot with 37 tools and 3 prompts enabled.</p>
<p>On macOS, use ⌘Q rather than closing the window when restarting. The MCP server process reads your API key on spawn. Closing the window doesn't trigger a respawn, so the key isn't re-read.</p>
<h2>Workflow 1: Fetch live data from a protected site</h2>
<p>The goal is clean Markdown from a page that sits behind an anti-bot challenge which is the category Cursor's built-in browsing can't reach.</p>
<p>Paste this into Cursor's agent:</p>
<pre><code class="language-text">Use the Zenrows MCP scrape tool to fetch https://www.scrapingcourse.com/antibot-challenge
and return the content as clean Markdown.

Do not use your built-in web browsing. Only use the Zenrows scrape tool.
</code></pre>
<p>Cursor's built-in browsing returns the challenge interstitial for this URL. Zenrows identifies the challenge, activates the appropriate bypass configuration, and returns the page behind it as Markdown.</p>
<h3>Why this approach</h3>
<p>The scrape tool prompt tells the agent to use Zenrows explicitly and not to fall back to built-in browsing. Without that instruction, Cursor may try its built-in access first and return the challenge page as a valid response. The explicit tool name in the prompt is what enforces the routing.</p>
<h2>Workflow 2: Extract structured data from a JavaScript-rendered page</h2>
<pre><code class="language-text">Use the Zenrows MCP scrape tool to fetch https://www.scrapingcourse.com/javascript-rendering
and extract a structured JSON object containing the first 5 products with their name and price.
Do not use your built-in web browsing.
</code></pre>
<p>The products on this page load dynamically after JavaScript runs. A plain HTTP request returns the HTML shell with an empty product grid. Zenrows renders the page in a full browser environment before returning it, so the <code>#product-grid</code> container is populated when the agent parses it.</p>
<p>The result is structured JSON the agent can pass directly into a downstream task. Workflow 3 does exactly that.</p>
<h2>Workflow 3: Generate TypeScript from live protected data</h2>
<p>This workflow demonstrates the two-level fetch pattern that most real scraping jobs require: a category listing that carries some fields, and individual product pages that carry the rest.</p>
<p>On Home Depot, the category listing returns product names and prices. SKU and model number exist only on each product's own page. The agent fetches the listing, then fetches each product page, then combines both data levels into a unified structure, and generates a TypeScript function that replicates that workflow programmatically.</p>
<pre><code class="language-text">Use the Zenrows MCP scrape tool to fetch
https://www.homedepot.com/b/Tools-Woodworking-Tools/N-5yc1vZc2gv?catStyle=ShowProducts

and return the first 5 products as structured JSON with name and price. Then fetch each
product's individual page and add its SKU and model number.
Set proxy country to US. Do not use your built-in web browsing.

Then write a TypeScript function that fetches and displays product details for a given
product name.
</code></pre>
<p><code>proxy_country: US</code> routes the request through a US IP address. A US retailer returns its normal catalogue and pricing to US IPs. Without this, the response varies by origin country, and product availability or pricing may differ from what US-based users see.</p>
<p>The agent combines both page levels into this structure:</p>
<pre><code class="language-json">{
  "source_url": "https://www.homedepot.com/b/Tools-Woodworking-Tools/N-5yc1vZc2gv?catStyle=ShowProducts",
  "products": [
    {
      "name": "Gorilla 4 fl. oz. Wood Glue",
      "price": "$3.98",
      "sku": "1003827526",
      "model_number": "62020"
    },
    {
      "name": "DEWALT 20V MAX XR Cordless Brushless Fixed Base Compact Router (Tool Only)",
      "price": "$249.00",
      "sku": "1004095707",
      "model_number": "DCW600B"
    },
    {
      "name": "Titebond 8 oz. Original Wood Glue",
      "price": "$3.69",
      "sku": "676828",
      "model_number": "5063"
    }
  ]
}
</code></pre>
<h3>Why this approach</h3>
<p>Each SKU and model number required a separate Zenrows fetch. That means five individual product page requests in addition to the category listing and six Zenrows calls total. Each returned real content, which is what let the agent map the structure correctly and generate code that handles the two-level case rather than assuming all fields come from one page.</p>
<p>The generated TypeScript function has this signature:</p>
<pre><code class="language-ts">export async function fetchAndDisplayProductDetails(
  productName: string,
  apiKey = process.env.ZENROWS_API_KEY ?? ""
): Promise&lt;HomeDepotProductDetails | null&gt;
</code></pre>
<p>It scrapes the category listing, finds a product by partial name match, fetches that product's page for SKU and model number, and prints the combined result. Run it with:</p>
<pre><code class="language-bash">export ZENROWS_API_KEY="your_api_key_here"
npm run build
npm run homedepot -- "Gorilla 4 fl. oz. Wood Glue"
</code></pre>
<p>Results are saved to <code>data/homedepot-products.json</code>.</p>
<h2>Zenrows vs Firecrawl in Cursor</h2>
<p>Both integrate with Cursor. Firecrawl is a fast, lightweight option for public pages where discovery and clean Markdown matter most. Zenrows is the better fit when the pages are protected, JavaScript-rendered, or part of a production workflow that runs on a schedule.</p>
<table>
<thead>
<tr>
<th>Scenario</th>
<th>Recommended</th>
<th>Reason</th>
</tr>
</thead>
<tbody><tr>
<td>Public blog or open documentation</td>
<td>Firecrawl</td>
<td>Fast Markdown from the open web</td>
</tr>
<tr>
<td>Multi-page crawl on public sites</td>
<td>Firecrawl</td>
<td>Built-in crawl and map workflows</td>
</tr>
<tr>
<td>Protected site behind Cloudflare</td>
<td>Zenrows</td>
<td>JS rendering and premium proxies handle challenge pages</td>
</tr>
<tr>
<td>JavaScript-rendered page with embedded payload</td>
<td>Zenrows</td>
<td>Renders the full page before extraction</td>
</tr>
<tr>
<td>Recently updated docs or pricing</td>
<td>Zenrows</td>
<td>Live fetch, not a cached snapshot</td>
</tr>
<tr>
<td>High-volume recurring data workflow</td>
<td>Zenrows</td>
<td>Batch processing, observability, and retry logic built in</td>
</tr>
<tr>
<td>Agent needs structured JSON output</td>
<td>Zenrows</td>
<td>Returns HTML, Markdown, JSON, screenshots, or plain text</td>
</tr>
</tbody></table>
<p>Full comparison: <a href="https://www.zenrows.com/blog/best-firecrawl-alternative-for-anti-bot-bypass">Zenrows vs Firecrawl</a></p>
<hr />
<h2>Debugging</h2>
<p><strong>Zenrows MCP isn't appearing after I added the config</strong></p>
<p>Validate your <code>mcp.json</code> with the command in Step 2, then fully restart Cursor with ⌘Q on macOS. The server process only reads the config on spawn — closing the window doesn't trigger a respawn.</p>
<p><strong>401 error in the agent response</strong></p>
<p>The server is connected but the API key it's passing is invalid. Check that <code>ZENROWS_API_KEY</code> in <code>mcp.json</code> matches a live key in your Zenrows account and isn't still the <code>your_api_key_here</code> placeholder. Save, quit Cursor fully, and reopen.</p>
<p><strong>EACCES error on macOS</strong></p>
<p>Restore npm cache ownership, then restart Cursor:</p>
<pre><code class="language-bash">sudo chown -R $(id -u):$(id -g) ~/.npm
</code></pre>
<p><strong>Prefer the hosted MCP server over running npx locally?</strong></p>
<p>Use <code>https://mcp.zenrows.com/mcp</code> with your API key as a Bearer token in Cursor's remote MCP configuration.</p>
<hr />
<h2>What to build next</h2>
<ul>
<li><p><a href="https://docs.zenrows.com/mcp/overview">Zenrows MCP overview</a>: full tool and prompt reference</p>
</li>
<li><p><a href="https://docs.zenrows.com/integrations/cursor">Cursor integration docs</a>: reference configuration for the integration</p>
</li>
<li><p><a href="https://www.zenrows.com/blog/pull-web-data-into-claude-cursor-and-any-ai-agent-with-zenrows-mcp">Zenrows MCP setup for Claude Desktop and other agents</a>: the same config pattern across MCP-compatible clients</p>
</li>
<li><p><a href="https://www.zenrows.com/blog/web-aware-agent-openai-agents-sdk-zenrows">OpenAI Agents SDK + Zenrows</a>: register Zenrows as a <code>@function_tool</code> in code instead of running an MCP server</p>
</li>
<li><p><a href="https://www.zenrows.com/blog/web-research-multi-agent-ag2-zenrows">AG2 + Zenrows multi-agent pipeline</a>: the same Zenrows fetch layer in a researcher–analyst–critic group chat</p>
</li>
</ul>
]]></content:encoded></item></channel></rss>