Author: Xavier Fok

  • Best Data Marketplace Platforms 2026: Where to Buy and Sell Datasets

    best data marketplace platforms 2026: where to buy and sell datasets

    the top 10 data marketplaces in 2026 are aws data exchange, snowflake marketplace, databricks marketplace, dawex, datarade, narrative.io, bright data dataset marketplace, kaggle datasets, datafiniti, and data.world. aws and snowflake dominate enterprise. dawex and datarade are the leading neutral platforms. kaggle stays the largest free pool for analysts. each handles delivery, payouts, and licensing differently.

    this guide breaks down what each platform sells, how sellers get paid, and which marketplace fits buyer use cases from b2b leads to alt-data hedge fund inputs.

    quick comparison: 2026 data marketplaces

    marketplace best for pricing model seller payout dataset count
    aws data exchange aws-native enterprise subscription, one-time 70/30 3,500+
    snowflake marketplace snowflake customers revenue share, subscription 90/10 2,000+
    databricks marketplace lakehouse users free or revenue share 90/10 1,000+
    dawex neutral b2b data one-time or subscription 80/20 600+
    datarade discovery-first varies by seller seller-set 2,500+
    narrative.io identity and audiences subscription varies 200+
    bright data scraped public data subscription n/a (single seller) 200+
    kaggle analysts and ml mostly free n/a 300,000+
    datafiniti retail and business listings api credits 70/30 n/a
    data.world community and open data freemium varies 100,000+

    aws data exchange

    aws data exchange is the default for enterprise buyers running on aws. data is delivered as files into your s3, redshift, or via api. payment runs through your aws bill so procurement is simple.

    categories include financial data (refinitiv, factset), healthcare (iqvia), location (here, foursquare), and weather (the weather company). entry prices range from free to $50,000 per month for premium feeds.

    sellers get 70 percent of revenue. integration with aws billing and analytics workloads is the moat. if your buyer or your team is already on aws, this is where to start.

    snowflake marketplace

    snowflake marketplace delivers data as live shares directly into your snowflake account. there is no etl or file copy; the data appears as a database you can query immediately.

    this is the cleanest delivery model in the industry. for snowflake-native teams it is faster to integrate than any other marketplace by an order of magnitude.

    sellers earn 90 percent of revenue, the highest split among major platforms. dataset count is smaller than aws but quality skews higher because most listings are vetted. categories include market data, identity graphs, and consumer panels.

    databricks marketplace

    databricks marketplace launched in 2023 and grew fast in 2025-2026. data is delivered as delta sharing tables, similar to snowflake’s live share but cross-cloud.

    it currently leans heavily on free public datasets and ai training corpora. paid commercial listings exist but the catalog is smaller than snowflake. sellers earn 90 percent on paid datasets.

    if your team is on databricks lakehouse, this is the natural fit. otherwise the snowflake or aws marketplaces have deeper paid catalogs.

    dawex

    dawex is the leading neutral b2b data exchange. it is cloud-agnostic, sells one-time and recurring data products, and runs in multiple regions including europe (gdpr-friendly defaults) and asia.

    their seller dashboard is the most polished outside the hyperscalers. you upload, set licensing terms, price per buyer or per region, and dawex handles payments and contracts.

    revenue share is 80/20 in favor of sellers. for vertical-specific datasets (mobility, energy, retail) dawex often has more depth than aws or snowflake.

    datarade

    datarade is more of a discovery layer than a marketplace. they list 2,500+ data products from 500+ providers, route buyer rfqs, and broker the deal. the actual data delivery happens off-platform between buyer and seller.

    this works well for buyers who want one search across many providers. it is less smooth than snowflake’s in-account share. seller pricing is fully provider-set.

    datarade is the right starting point if you do not yet know which provider has what you need. their search filters by category, geography, and use case are the best in the industry.

    narrative.io

    narrative.io specializes in identity, audience, and consumer data. they run a real-time bidding-style data ops platform where data is licensed in continuous streams, not files.

    if you are buying audience segments for ad targeting, customer enrichment for crm, or identity graphs for fraud, narrative is purpose-built. for static datasets, look elsewhere.

    bright data dataset marketplace

    bright data sells pre-scraped public web data as datasets: linkedin profiles, amazon products, instagram, tiktok, glassdoor reviews, indeed jobs, and 200+ more. you buy the most recent snapshot or subscribe to refreshes.

    this is single-seller (bright data only) but the depth on public web data is unmatched. for competitive intelligence, e-commerce pricing, and social listening, the time-to-data is faster than scraping yourself.

    pricing is per record or per gb. for context on pricing benchmarks see our proxy pricing comparison and the best web scraping apis guide.

    kaggle datasets

    kaggle hosts 300,000+ free datasets used mainly by analysts, ml engineers, and competition participants. licensing varies; many are creative commons or public domain.

    it is not a commercial marketplace. there is no payment infra and no commercial licensing layer. for prototyping a model or learning, it is the largest free pool. for production data licensing, look at the platforms above.

    datafiniti

    datafiniti specializes in business listings, product data, and property records as api endpoints. you pay per query or per record. the data is scraped and normalized from public sources.

    it competes with bright data’s dataset marketplace and outscraper for similar use cases (lead enrichment, retail intelligence). for our take on the b2b lead tool category see outscraper vs phantombuster vs hunter.io.

    data.world

    data.world started as an open data community. in 2024 it pivoted to enterprise data catalog and governance, and the marketplace component is now secondary to their cataloging product.

    for free open data with social features (comments, queries, shared notebooks) it remains useful. for commercial licensing it is no longer competitive.

    buyer checklist before purchasing

    four checks save weeks of contract back-and-forth.

    licensing. confirm whether the data is licensed for internal use, commercial product use, or resyndication. these are three different price tiers on most platforms.

    freshness. ask the seller for the typical data delivery cadence (real-time, daily, weekly, monthly) and what “current” means in their schema. some “live” feeds are 24 hours stale.

    geography and pii. for eu and uk buyers, confirm gdpr basis. for california buyers, confirm ccpa compliance. data marketplaces are intermediaries, not regulators.

    trial access. every reputable marketplace offers a sample. always pull 1,000 rows before committing to a year-long contract. many “verified” datasets are not what the marketing claims.

    for news data buyers specifically see our news apis comparison which covers similar buying decisions for content feeds.

    seller checklist before listing

    want to monetize data you already collect? four things matter.

    audience. listing on snowflake reaches snowflake customers. listing on aws reaches aws customers. listing on dawex reaches everyone but with less integration. pick by where your buyers already work.

    revenue share. snowflake and databricks pay sellers 90 percent. aws is 70 percent. dawex is 80 percent. for high-volume listings the difference compounds.

    delivery effort. live share platforms (snowflake, databricks) handle data ops for you. file delivery platforms (aws to s3) need more buyer-side wiring. for solo sellers, live share platforms are easier.

    contracts. most platforms ship a default eula. read the resale, sublicensing, and warranty clauses. dawex and aws let you customize terms more than snowflake.

    faq

    what is the largest data marketplace in 2026?

    aws data exchange has the most paid commercial listings at 3,500+. kaggle has the largest dataset count overall at 300,000+ but most are free and non-commercial.

    what does a data marketplace charge sellers?

    snowflake and databricks take 10 percent. dawex takes 20 percent. aws takes 30 percent. datarade and data.world have variable terms set by the seller. budget the platform cut into your retail price.

    can i buy real-time data from a marketplace?

    yes. snowflake live shares update in real time within snowflake. aws data exchange supports real-time api feeds for some sellers. for streaming-heavy use cases (ad targeting, fraud) narrative.io is purpose-built.

    how do i evaluate dataset quality before buying?

    every reputable marketplace offers a sample or trial. pull at least 1,000 rows, profile completeness, freshness, and schema accuracy, then run your actual production query against it. if the seller refuses a sample, walk away.

    is data marketplace licensing compliant with gdpr?

    it depends on the dataset and the seller’s lawful basis. eu and uk buyers should require the seller to disclose lawful basis (consent, legitimate interest, contract) and the data subject’s rights process. for context see the european commission data act overview.

    do hedge funds buy from data marketplaces?

    yes. alt-data is a multi-billion-dollar segment in 2026. funds typically buy from snowflake marketplace, aws data exchange, or specialized providers (yipitdata, second measure) directly. neutral marketplaces like dawex are less common in this segment.

    the bottom line

    for enterprise buyers on aws, snowflake, or databricks, use the marketplace native to your stack. for cross-cloud buyers, dawex is the cleanest neutral platform. for discovery, datarade is the search engine of data marketplaces.

    for sellers, snowflake and databricks pay the best revenue share at 90 percent and handle data ops via live share. aws reaches the largest enterprise buyer base at a 70/30 cut. dawex sits in the middle on both axes and wins on neutrality.

    run a sample pull before any contract. the gap between “marketplace listing” and “production-ready data feed” is wider than most buyers expect.

  • How to Bypass PerimeterX (Human Presence Detection) for Web Scraping

    How to Bypass PerimeterX (Human Presence Detection) for Web Scraping

    bypassing perimeterx in 2026 means three things working together: a residential or mobile ip with a clean asn, a real chromium browser with patched fingerprints, and either a working sensor data payload or a managed unlocker that produces one for you. plain http clients fail. headless puppeteer with default settings fails. this is the working approach with code.

    what perimeterx actually is

    perimeterx (now called human security after the 2022 rebrand, but the technology is unchanged) is one of the four major commercial anti-bot vendors alongside akamai, datadome, and cloudflare. it powers anti-bot for many large retail, ticketing, sneaker, and travel sites.

    their core trick is sensor data. on every page, perimeterx injects a heavily obfuscated javascript blob that fingerprints your browser dozens of ways (canvas, webgl, audio, fonts, plugins, screen, timing, mouse, keyboard, even tab focus events) and bundles the result into a _pxhd token that gets posted back to perimeterx’s classifier. the server then issues a cookie called _px3 that says “this client looks human” or “this client looks like a bot.”

    without a valid _px3 cookie, you get a 403 with a captcha challenge. with a poisoned _px3, you get rate-limited, served fake data, or quietly throttled.

    if you’ve already seen our akamai bypass guide and datadome bypass guide, perimeterx is closer to akamai than datadome in execution. denser obfuscation, but the same general game.

    the three failure modes

    (1) ip-level block. happens before any javascript runs. you hit the site from a datacenter ip and get a 403 challenge page on the first request.

    (2) fingerprint-level block. javascript runs, sensor data is collected, perimeterx classifies the client as a bot. you get a 403 with the human security challenge ui.

    (3) behavioral block. you pass fingerprint checks but make 100 rapid sequential requests with no scroll, no mouse, no realistic timing. perimeterx flags after the burst and starts serving challenges.

    each layer needs a distinct fix.

    fix 1: residential or mobile ips

    datacenter is dead on perimeterx-protected sites. residential is the floor. mobile is preferred for ticketing and sneakers.

    import httpx
    
    proxy = "http://user-country-us-session-abc123:pwd@gate.provider.com:8000"
    
    with httpx.Client(proxies=proxy, http2=True) as client:
        resp = client.get("https://www.protected-site.com/")
        print(resp.status_code)
    

    if you get 200 with a real page, the ip is clean. if you get 403, the ip is burnt or the site requires a browser, not just headers.

    residential pools that work well on perimeterx in 2026: bright data, oxylabs, smartproxy, soax. avoid anything sold as “high-rotation” or “datacenter residential” hybrids.

    fix 2: a real chromium browser with sensor data

    http clients can’t pass perimeterx because there’s no javascript engine to run the sensor scripts. you need a real browser. options:

    (1) playwright with chromium and stealth patches.

    (2) puppeteer with puppeteer-extra-plugin-stealth.

    (3) a managed scraping browser (bright data, zyte, scrapfly) that runs perimeterx-aware browsers for you.

    option 3 is the lowest-effort path. options 1 and 2 give you full control but require maintenance.

    from playwright.async_api import async_playwright
    
    async def scrape_perimeterx_site(url, proxy):
        async with async_playwright() as p:
            browser = await p.chromium.launch(
                headless=False,  # headless detection is real, prefer headed if possible
                proxy={
                    "server": proxy.split("@")[1].split("/")[0],
                    "username": proxy.split("//")[1].split(":")[0],
                    "password": proxy.split(":")[2].split("@")[0],
                },
                args=[
                    "--disable-blink-features=AutomationControlled",
                    "--disable-features=IsolateOrigins,site-per-process",
                ],
            )
    
            context = await browser.new_context(
                user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
                viewport={"width": 1920, "height": 1080},
                locale="en-US",
                timezone_id="America/New_York",
            )
    
            # patch navigator.webdriver
            await context.add_init_script("""
                Object.defineProperty(navigator, 'webdriver', {
                    get: () => undefined,
                });
            """)
    
            page = await context.new_page()
            await page.goto(url, wait_until="networkidle")
    
            # wait for perimeterx sensor to settle
            await page.wait_for_timeout(3000)
    
            # do something human-ish first
            await page.mouse.move(500, 400)
            await page.mouse.move(700, 600, steps=20)
            await page.evaluate("window.scrollBy(0, 500)")
    
            html = await page.content()
            await browser.close()
            return html
    

    key parts:

    headless=False matters. headless chrome has subtle differences (missing fonts, different webgl, no real display) that perimeterx detects. if you must run headless, use playwright’s persistent context with a saved profile and accept lower success rates.

    --disable-blink-features=AutomationControlled removes the chrome banner that says “chrome is being controlled by automated software.” this also clears one of the easiest fingerprint flags.

    the navigator.webdriver patch hides another obvious flag.

    mouse movement and scroll before any meaningful action signals “real user” to perimeterx’s behavioral model.

    fix 3: behavioral patterns

    perimeterx scores per-session behavior. patterns that fail:

    (1) no mouse movement during the session.
    (2) instant clicks (less than 100ms after page load).
    (3) sequential url fetching with millisecond gaps.
    (4) absent or static viewport.
    (5) no tab visibility changes (real users tab away and back).

    what works:

    import random
    
    async def humanize_session(page):
        # random initial scroll
        await page.evaluate(f"window.scrollBy(0, {random.randint(100, 500)})")
        await page.wait_for_timeout(random.randint(800, 2000))
    
        # mouse jiggle
        for _ in range(random.randint(2, 5)):
            await page.mouse.move(
                random.randint(200, 1700),
                random.randint(200, 900),
                steps=random.randint(10, 30),
            )
            await page.wait_for_timeout(random.randint(200, 800))
    
        # second scroll, deeper
        await page.evaluate(f"window.scrollBy(0, {random.randint(300, 800)})")
        await page.wait_for_timeout(random.randint(1000, 2500))
    

    call this between page loads. add 2-5 seconds of “humanizing” per page. throughput drops, success rates climb.

    using a managed scraping browser

    if you don’t want to fight perimeterx fingerprints yourself, the easiest path is a managed scraping browser. bright data, zyte, and scrapfly all sell one. you connect to their browser via cdp (chrome devtools protocol) and they handle the patching:

    from playwright.async_api import async_playwright
    
    async def via_managed_browser():
        async with async_playwright() as p:
            # connect to bright data scraping browser
            browser = await p.chromium.connect_over_cdp(
                "wss://brd-customer-XXX-zone-scraping_browser:PASSWORD@brd.superproxy.io:9222"
            )
    
            page = await browser.new_page()
            await page.goto("https://protected-site.com/", wait_until="networkidle")
            html = await page.content()
            await browser.close()
            return html
    

    you pay $5-15 per gigabyte of bandwidth, and the success rate on perimeterx sites typically clears 95%. for production scraping where engineering time costs more than infrastructure, this is the right tradeoff.

    handling the human security challenge directly

    if you do hit a challenge page, you have two options:

    (1) accept defeat for that ip and rotate. if the proxy pool is large enough, retrying with a fresh session usually works.

    (2) solve the captcha. capsolver and 2captcha both offer perimeterx-specific solvers. cost is $1-3 per 1000 solves. response time is 10-30 seconds.

    the captcha route is slower and costs more per request, but for sites where every page matters (low volume, high value), it’s viable.

    site-by-site difficulty in may 2026

    site perimeterx strictness working approach
    stockx.com very high managed browser only
    fanatics.com high playwright + residential + behavioral humanizer
    ticketmaster.com very high managed browser + mobile ip
    zillow (some endpoints) medium playwright + residential
    nfl.com / nba.com shop medium playwright + residential
    many smaller retail sites low playwright + residential is enough

    the strictest perimeterx sites are nearly bypass-resistant for unmanaged scrapers. they’re also the most valuable to scrape, which is why managed browsers exist as a profitable product category.

    fingerprint hygiene checklist

    before deploying a perimeterx scraper, verify:

    • user-agent matches the browser version actually running (chrome 126 ua + chrome 117 binary = flagged immediately).
    • viewport is 1920×1080 or another common real-user resolution. avoid 1280×720 default.
    • timezone matches the proxy’s geo (us proxy + asia/tokyo timezone = flagged).
    • locale matches the proxy’s geo.
    • webgl vendor and renderer aren’t swiftshader (which signals headless or virtualized).
    • canvas fingerprint isn’t the famously-broken default headless chrome canvas.
    • audio context fingerprint matches a real browser.
    • navigator.plugins, navigator.languages, navigator.platform are all populated and consistent.

    a tool like creepjs or amiunique.org can show you what your browser leaks. compare against a real laptop’s fingerprint. close the gap as much as you can.

    frequently asked questions

    why does my puppeteer-extra-stealth not work on stockx?

    stockx is one of the strictest perimeterx deployments. stealth plugin fixes the easy fingerprints (navigator.webdriver, chrome runtime, plugins) but doesn’t address sensor-data analysis. you need a managed browser or an actual perimeterx-token-mining setup.

    can i replay a captured _px3 cookie across many requests?

    short windows yes (a few minutes), but perimeterx detects token reuse across too many requests or too long a window and burns the token. better to acquire a fresh token per session.

    is human security different from perimeterx?

    it’s the rebranded company name. the technology is the same. anyone in scraping still calls it perimeterx because the tooling and bypass techniques didn’t change with the rebrand.

    how does perimeterx compare to datadome in difficulty?

    datadome is faster and lighter, perimeterx is heavier and more thorough. perimeterx is harder to bypass at scale because the sensor data analysis is more sophisticated. on protected mid-tier sites, datadome is solvable with stealth playwright, perimeterx often isn’t.

    do mobile ips help against perimeterx?

    yes, marginally. mobile asns get higher trust scores. but if your fingerprint is bad, even a clean mobile ip won’t save you. ip is the floor, fingerprint is the ceiling.

    is there an open-source perimeterx solver?

    no working public ones in 2026. the obfuscation is updated frequently and reverse-engineering it is full-time work. a few private solvers exist within scraping firms, sold to enterprise customers. open-source efforts get burned within weeks of release.

    final thoughts

    perimeterx isn’t bypassed with a single trick. it’s bypassed with a stack: clean ip, real browser, patched fingerprint, human-like behavior, and either an in-house token-mining setup or a managed browser. for small projects, accept that some perimeterx sites are out of reach. for valuable targets, pay for the managed browser. fighting perimeterx alone with playwright + residential proxies works on the medium-tier sites and fails predictably on the top tier.

    if you’re already shipping bypass code for cloudflare turnstile and akamai, perimeterx is the next graduation step in the same cluster. each one teaches the same lessons: ip, fingerprint, behavior, and patience.

  • Apify vs Bright Data vs Oxylabs: Managed Scraping Platforms Compared (2026)

    Apify vs Bright Data vs Oxylabs: Managed Scraping Platforms Compared (2026)

    apify is the developer-friendly platform with a marketplace of 3000+ pre-built scrapers (called actors). bright data has the deepest catalog of pre-scraped datasets and the most complete proxy + unlocker infrastructure. oxylabs is the enterprise-grade api stack with the cleanest docs and the strongest sla. they overlap but serve different buyers. this is the may 2026 comparison with prices from each dashboard.

    the matrix

    feature apify bright data oxylabs
    pre-built scrapers 3000+ actors ~200 datasets + scrapers scraper apis for major sites
    custom code support yes (node, python, any docker) limited (yaml templates) no, api-only
    proxy network included yes (datacenter, residential) yes (full network) yes (full network)
    starter price $49/month, 49 platform credits $499/month + usage $99/month + usage
    pay-as-you-go yes, $0.40 per cu yes yes
    free tier $5 credit/month forever 7-day trial w/ kyc 7-day trial
    serp api yes (apify google scraper) yes (serp api product) yes (web scraper api)
    dataset marketplace yes (data store) yes (dataset store) no
    storage included 9gb on starter per-dataset pricing 5gb on starter
    developer ergonomics best of three medium medium
    onboarding speed minutes 24-48h with kyc hours
    best at building custom scrapers + selling them total proxy + data infrastructure clean enterprise apis

    prices change. snapshot is may 1, 2026.

    apify: the developer platform

    apify is the only one of the three that’s a real platform-as-a-service for scraping. you write a docker container that scrapes some target, push it to apify, and they run it on their infrastructure. they call this an “actor.”

    3000+ public actors exist already. amazon scraper, instagram scraper, google maps scraper, linkedin scraper, twitter scraper, facebook ads library scraper, youtube scraper. most are maintained by apify themselves or trusted community devs.

    from apify_client import ApifyClient
    
    client = ApifyClient("YOUR_API_TOKEN")
    
    # run the official google search scraper actor
    run = client.actor("apify/google-search-scraper").call(run_input={
        "queries": "best mobile proxies singapore",
        "maxPagesPerQuery": 3,
        "resultsPerPage": 100,
        "countryCode": "us",
    })
    
    # fetch results
    for item in client.dataset(run["defaultDatasetId"]).iterate_items():
        print(item["title"], item["url"])
    

    5 lines of code, no scraper to maintain. you pay the platform usage (compute units + bandwidth) plus the actor’s per-result fee where applicable.

    apify pricing is denominated in compute units (cu). 1 cu is roughly 1gb-hour of memory + cpu. a typical google search scrape uses 0.1-0.5 cu. the $49/month starter plan gives you 49 cu, which goes far for hobbyist scraping.

    what’s bad: apify’s residential proxy pool is smaller than bright data’s or oxylabs’. for protected sites, you’re better off pairing apify’s compute platform with an external residential proxy. they support this directly in actor inputs.

    we cover apify in our web scraping apis for developers 2026 article.

    bright data: the data and infrastructure giant

    bright data is the largest of the three by revenue and pool size. their proxy network is the deepest, their web unlocker handles the meanest sites, and their dataset catalog is unmatched.

    three product lines:

    (1) proxy network. residential, datacenter, isp, mobile. all the standard tools.

    (2) managed apis. web unlocker, serp api, scraping browser, data collector. these are bright data’s “we’ll do the scraping for you” stack.

    (3) datasets. pre-scraped data sold by record. you don’t run a scraper, you just pay for the rows. linkedin profiles, amazon product data, zillow listings, instagram public posts.

    import requests
    
    # trigger a managed scrape via web unlocker
    resp = requests.get(
        "https://api.brightdata.com/dca/trigger",
        headers={"Authorization": "Bearer YOUR_TOKEN"},
        json={
            "url": "https://www.target-site.com/products/123",
            "render": True,
        },
    )
    

    bright data’s strength is breadth + scale. if you’re scraping at million-page-per-day volumes or need 100% success on sites with serious anti-bot, bright data is the lowest-risk pick.

    starter plan is $499/month with usage-based billing on top. expensive entry. for under $1000/month spend, you’ll feel the price more than the technical advantage.

    we go deep on bright data in our proxy providers ultimate guide.

    oxylabs: the enterprise scraping api stack

    oxylabs is the cleanest of the three for engineers who want straightforward apis with no marketplace, no datasets, and no actor system. their core scraping products:

    (1) web scraper api. send a url, get back html or auto-extracted json.

    (2) serp scraper api. send a query, get back parsed search results.

    (3) ecommerce scraper api. send a url to amazon/ebay/walmart/etc, get back parsed product json.

    (4) real estate scraper api. send a url to zillow/realtor/redfin/etc, get back parsed listing json.

    import requests
    
    resp = requests.post(
        "https://realtime.oxylabs.io/v1/queries",
        auth=("USER", "PASS"),
        json={
            "source": "ecommerce_product",
            "url": "https://www.amazon.com/dp/B0CHX1W1XY",
            "parse": True,
        },
    )
    print(resp.json()["results"][0]["content"])
    

    response includes parsed product fields ready for ingestion. pricing is per request, $0.001-$0.005/req depending on source and volume tier.

    oxylabs’ strength is api cleanliness and reliability. their dashboards are fast, their docs are excellent, and their support team is 24/7 with strong sla. for an enterprise data team that wants “we send urls, you give us clean data,” oxylabs is the easiest sell internally.

    what’s bad: no marketplace. if you want a tiktok scraper or a youtube scraper, oxylabs doesn’t have one. you’d need to build it yourself using their proxy network.

    use case decision tree

    (1) you want to scrape a major site (google, amazon, instagram, linkedin) and don’t want to write or maintain a scraper. all three work, but apify’s actor catalog is the deepest. if the actor exists, apify wins on developer time saved.

    (2) you want to buy pre-scraped data without running scrapers at all. bright data datasets is the broadest catalog. apify also has dataset listings on their store but volume is smaller.

    (3) you scrape protected sites at high volume and need near-perfect success. bright data web unlocker. expensive but the success rate justifies the spend.

    (4) you want clean apis with predictable pricing for major ecommerce or real estate sites. oxylabs is the cleanest.

    (5) you’re building your own scraper and want a hosting platform that handles concurrency, retries, and storage. apify is the only one of the three. bright data and oxylabs sell apis, not platforms.

    (6) you have an enterprise procurement process and need vendor-grade contracts, sla, and account managers. oxylabs and bright data both qualify. apify can but is more startup-coded.

    actual cost comparison

    pricing varies by use case. some realistic monthly bills based on real customers we’ve talked to:

    use case apify bright data oxylabs
    scrape 50k google search queries/month $80-150 $250-400 $200-350
    scrape 100k amazon product pages/month $120-300 $500-1000 $300-700
    scrape 1M cloudflare-protected pages/month $1500-3000 $1000-2500 $1500-3000
    buy 100k linkedin profiles as a dataset $500-2000 $300-1500 n/a

    bright data wins on extreme-volume protected scraping (the unlocker is genuinely cheaper at scale). apify wins on small-to-medium custom scraping where actor reuse pays off. oxylabs wins on clean enterprise spend predictability.

    developer experience

    apify: best of the three. apify cli, sdk in python and node, actor sdk for building your own, dashboard with logs, dataset viewer, scheduler, alerts. you can ship a custom scraper in an afternoon.

    bright data: most surface area, steepest learning curve. multiple products with overlapping features. dashboard is feature-rich but takes time to learn. docs are thorough but scattered.

    oxylabs: cleanest of the three for the api-only flow. one endpoint pattern (POST /v1/queries with a source parameter) covers most use cases. minimal cognitive load.

    marketplace quality on apify

    since apify’s marketplace is the unique selling point, here’s a snapshot of what’s actively maintained vs abandoned:

    category well-maintained actors
    google (search, maps, news, shopping) apify/google-search-scraper, apify/google-maps-scraper
    amazon apify/amazon-product-scraper, apify/amazon-reviews-scraper
    instagram apify/instagram-scraper
    linkedin apify/linkedin-profile-scraper (subject to platform changes)
    youtube apify/youtube-scraper
    tiktok clockworks/free-tiktok-scraper
    twitter/x apify/twitter-scraper
    reddit trudax/reddit-scraper
    zillow / realtor various, quality varies

    community actors range from excellent to abandoned. always check last-update date and run count before depending on one.

    reliability and uptime

    bright data and oxylabs both publish 99.9%+ uptime sla on enterprise plans. real-world data backs this.

    apify is at 99.7-99.8% on their compute platform. occasional dataset api hiccups. for non-critical scraping this is fine, for time-sensitive feeds you’d want fallback paths.

    if your scraper is mission-critical (real-time pricing for trading, fraud detection feed), bright data or oxylabs are safer. for everything else, apify is plenty.

    frequently asked questions

    what’s the cheapest of the three for a hobbyist?

    apify, by a wide margin. the $5 free monthly credit covers light hobby use. bright data and oxylabs are designed for paying customers.

    can i use bright data’s proxies inside apify actors?

    yes. apify actors support custom proxy configs. many people use apify for the compute + scheduler + storage and bright data for the proxies.

    is apify safer than running scrapers myself?

    operationally, yes. they handle retries, error logging, scheduled runs, dataset storage, and monitoring. legally, no. you’re still responsible for what you scrape and how you use the data.

    which platform is best for scraping linkedin?

    apify has actively maintained linkedin actors. bright data has linkedin in their dataset catalog. oxylabs supports linkedin via the web scraper api but with stricter usage limits. apify is the most flexible.

    do any of them solve captchas natively?

    bright data’s web unlocker and oxylabs’ web scraper api solve common captchas as part of the managed flow. apify actors integrate captcha solvers via configurable inputs but you bring your own solver budget.

    what about smaller competitors like scrapfly or zyte?

    different category but overlapping. zyte and scrapfly compete more directly with bright data web unlocker on the api side. neither has apify’s marketplace or bright data’s dataset catalog. for a focused choice between unblocking apis, see our scraperapi vs zyte article.

    final thoughts

    if you’re a developer or small team looking to ship custom scrapers fast, apify is your platform. if you’re an enterprise that needs the deepest infrastructure and dataset catalog, bright data is the safe pick. if you want clean apis with predictable enterprise sla and don’t need a marketplace, oxylabs is the cleanest.

    most production stacks i’ve seen end up using two of the three. apify for custom one-off scrapers, bright data for the heavy unlocking work or pre-scraped datasets. oxylabs for ecommerce-specific apis where their parsing quality saves engineering time. you don’t have to pick just one.

  • How to Scrape Realtor.com Property Data in 2026 (Bypass Next.js Protection)

    How to Scrape Realtor.com Property Data in 2026 (Bypass Next.js Protection)

    the cleanest way to scrape realtor.com in 2026 is to extract the embedded __NEXT_DATA__ json from each listing page. it contains everything the website renders, in structured form, with no parsing brittleness. you’ll need a residential proxy to avoid the akamai bot manager block, and python with httpx + parsel does the rest. this tutorial ships working code.

    why next_data is the trick

    realtor.com runs on next.js. every server-rendered page bakes a hidden <script id="__NEXT_DATA__" type="application/json"> block into the html. inside that block sits the entire react state for the page: full property details, agent info, school info, pricing history, photos, the whole structured tree.

    if you parse the html directly (price from .price-display, address from .address-line), realtor.com will rename or restructure that markup every few months. your scraper breaks. if you parse __NEXT_DATA__, you get raw json from their backend, and they rarely change those keys because their own frontend depends on them.

    we cover this pattern in depth in our javascript-rendered pages scraping guide, but realtor.com is the textbook case.

    the anti-bot situation

    realtor.com sits behind akamai bot manager and a custom rate limiter. behavior:

    (1) datacenter ips: blocked at the cdn edge. you get a 403 with a captcha challenge page.

    (2) residential or mobile ips with a clean fingerprint: 200 ok response, 50-200 requests per ip per hour before throttling.

    (3) high-volume requests from the same ip: 429 rate limit, 5-15 minute cooldown.

    practical implication: you need rotating residential ips with sticky sessions long enough to fetch a single page. you don’t need a full headless browser, plain http requests with the right headers work fine.

    installing dependencies

    pip install httpx parsel orjson
    

    httpx for async http, parsel for css selectors, orjson for fast json parsing. that’s it.

    the basic listing fetcher

    import httpx
    import parsel
    import orjson
    
    HEADERS = {
        "User-Agent": (
            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
            "AppleWebKit/537.36 (KHTML, like Gecko) "
            "Chrome/126.0.0.0 Safari/537.36"
        ),
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9",
        "Accept-Language": "en-US,en;q=0.9",
        "Accept-Encoding": "gzip, deflate, br",
        "Cache-Control": "no-cache",
        "Pragma": "no-cache",
        "Sec-Ch-Ua": '"Chromium";v="126", "Not(A:Brand";v="24", "Google Chrome";v="126"',
        "Sec-Ch-Ua-Mobile": "?0",
        "Sec-Ch-Ua-Platform": '"macOS"',
        "Sec-Fetch-Dest": "document",
        "Sec-Fetch-Mode": "navigate",
        "Sec-Fetch-Site": "none",
        "Sec-Fetch-User": "?1",
        "Upgrade-Insecure-Requests": "1",
    }
    
    def fetch_listing(url: str, proxy: str) -> dict:
        with httpx.Client(
            proxies=proxy,
            headers=HEADERS,
            timeout=30,
            http2=True,
        ) as client:
            resp = client.get(url)
            resp.raise_for_status()
    
            sel = parsel.Selector(resp.text)
            data = sel.css("script#__NEXT_DATA__::text").get()
            if not data:
                raise ValueError("__NEXT_DATA__ missing - likely blocked")
    
            return orjson.loads(data)
    
    if __name__ == "__main__":
        proxy = "http://user-session-abc:pwd@gate.provider.com:8000"
        data = fetch_listing(
            "https://www.realtor.com/realestateandhomes-detail/123-Main-St_Anytown_CA_90210_M12345-67890",
            proxy,
        )
        print(orjson.dumps(data, option=orjson.OPT_INDENT_2).decode())
    

    key headers:

    Sec-Ch-Ua block must match the user-agent. mismatched user-agent and sec-ch-ua is one of the fastest ways to get flagged.

    http2 enabled. realtor.com uses http2 internally and bot managers flag plain http/1.1 client behavior on http2 sites.

    navigating next_data

    the json structure looks like this (pruned):

    {
      "props": {
        "pageProps": {
          "initialReduxState": {
            "propertyDetails": {
              "property": {
                "list_price": 750000,
                "address": {
                  "line": "123 Main St",
                  "city": "Anytown",
                  "state": "CA",
                  "postal_code": "90210"
                },
                "description": {
                  "beds": 3,
                  "baths": 2.5,
                  "sqft": 1800,
                  "year_built": 1985,
                  "type": "single_family"
                },
                "photos": [...],
                "advertisers": [...],
                "schools": {...},
                "tax_history": [...]
              }
            }
          }
        }
      }
    }
    

    the exact path varies slightly between listing types (single family, condo, lot, rental). a robust extractor walks the tree:

    def extract_property(data: dict) -> dict:
        page_props = data["props"]["pageProps"]
        redux = page_props.get("initialReduxState", {})
        prop = redux.get("propertyDetails", {}).get("property", {})
    
        if not prop:
            # fallback for newer page structures
            prop = page_props.get("property", {})
    
        return {
            "price": prop.get("list_price"),
            "address": prop.get("address", {}).get("line"),
            "city": prop.get("address", {}).get("city"),
            "state": prop.get("address", {}).get("state"),
            "postal_code": prop.get("address", {}).get("postal_code"),
            "beds": prop.get("description", {}).get("beds"),
            "baths": prop.get("description", {}).get("baths"),
            "sqft": prop.get("description", {}).get("sqft"),
            "year_built": prop.get("description", {}).get("year_built"),
            "property_type": prop.get("description", {}).get("type"),
            "photos": [p.get("href") for p in prop.get("photos", [])],
            "agent_name": (prop.get("advertisers", [{}])[0]).get("name"),
            "schools": prop.get("schools"),
            "tax_history": prop.get("tax_history"),
        }
    

    now you have clean structured data ready for a database.

    scraping search results

    the listing detail page is the easy part. search result pages also embed __NEXT_DATA__ with a list of properties:

    def fetch_search_results(city: str, state: str, page: int, proxy: str) -> list:
        url = f"https://www.realtor.com/realestateandhomes-search/{city}_{state}/pg-{page}"
        with httpx.Client(proxies=proxy, headers=HEADERS, timeout=30, http2=True) as client:
            resp = client.get(url)
            resp.raise_for_status()
            sel = parsel.Selector(resp.text)
            data = orjson.loads(sel.css("script#__NEXT_DATA__::text").get())
    
        listings = data["props"]["pageProps"]["properties"]
        return [
            {
                "property_id": l.get("property_id"),
                "url": f"https://www.realtor.com{l.get('rdc_web_url', '')}",
                "list_price": l.get("list_price"),
                "address": l.get("address"),
            }
            for l in listings
        ]
    

    a typical search returns 42 listings per page. multi-page pagination is just incrementing pg-N until the result list is empty.

    the proxy setup

    residential rotating with 1-5 minute sticky sessions is what you want. one ip per page fetch keeps the request footprint tiny. avoid mobile (overkill, more expensive) and datacenter (blocked).

    import uuid
    
    def make_session_proxy() -> str:
        sid = uuid.uuid4().hex[:12]
        return f"http://user-country-us-session-{sid}:pwd@gate.provider.com:8000"
    

    generate a fresh session per page. if a request fails, generate another and retry.

    handling 403 challenges

    when akamai flags you, the response is a redirect to a challenge page or an html with "unable to verify" in the body. detect both:

    def looks_blocked(resp: httpx.Response) -> bool:
        if resp.status_code == 403:
            return True
        body = resp.text.lower()
        if "unable to verify" in body or "challenge" in body:
            return True
        if "<script id=\"__next_data__\"" not in body:
            return True
        return False
    

    retry with a fresh session. if you get blocked 3 times in a row from different sessions, the entire ip range is hot. wait 10-15 minutes before retrying.

    throttling for politeness

    even with rotating ips, hammering realtor.com is rude and gets your provider’s pool flagged. cap your request rate:

    import asyncio
    import random
    
    async def throttled_fetch(url, proxy):
        await asyncio.sleep(random.uniform(2.0, 5.0))
        return await async_fetch_listing(url, proxy)
    

    2-5 second delay per worker, 10-20 workers in parallel. you’ll fetch 200-400 pages/minute, which is plenty without abusing the site.

    storing the data

    a postgres table for structured fields, jsonb column for the full extracted payload (so you can backfill new fields later):

    CREATE TABLE realtor_listings (
        property_id TEXT PRIMARY KEY,
        scraped_at TIMESTAMP NOT NULL DEFAULT NOW(),
        list_price NUMERIC(12, 2),
        address TEXT,
        city TEXT,
        state TEXT,
        postal_code TEXT,
        beds NUMERIC(4, 1),
        baths NUMERIC(4, 1),
        sqft INTEGER,
        year_built INTEGER,
        property_type TEXT,
        raw JSONB,
        UNIQUE(property_id, scraped_at)
    );
    

    if your data lands in bigquery instead of postgres, the same pattern works (we wrote a scraping to bigquery pipeline that fits this scraper directly).

    scaling considerations

    at 200 listings/min, scraping all active us listings (~1.5M) takes ~5 days. realistic budgets:

    • proxy bandwidth: ~150kb per detail page = 250mb for 1500 listings. at $4/gb residential = $1/1500 listings.
    • compute: a single python worker handles 200/min. for parallel scraping, deploy 5-10 workers across regions.
    • storage: a million listings is ~3gb in postgres with the jsonb column.

    cheap relative to the data value. real estate scraping pipelines that produce $5k-50k/month in saas revenue spend $200-800/month on infrastructure.

    legal and ethical notes

    scraping public listings is widely accepted. the data is published for the world to see. but: realtor.com’s tos forbids automated access. they can block your ip range, send a cease-and-desist, or pursue legal action if you redistribute their data commercially.

    (1) don’t republish realtor.com’s photos or copy. fair use for analysis is one thing, building a competing listings site is another.

    (2) don’t scrape pii (broker phone numbers, emails) and resell it.

    (3) respect throttling. if they ratelimit, back off.

    your use case (price analysis, market trends, lead generation for buyers) is usually fine. competing directly with mls licensees is a fast way to get sued.

    frequently asked questions

    why doesn’t realtor.com just block next_data entirely?

    their own website depends on it. removing the embedded json would break their progressive enhancement and seo. they could obfuscate keys, but they haven’t, because the cost of breaking their own analytics tooling outweighs the gain of slowing scrapers.

    can i scrape with playwright instead of httpx?

    you can, but it’s overkill. realtor.com’s next_data is in the initial server response, so you don’t need to wait for js execution. httpx is 10x faster and 50x cheaper.

    what residential proxy provider works best for realtor.com?

    any reputable residential pool with us ips. our proxy provider comparison ranks them. avoid datacenter and avoid the smaller regional providers, your success rate suffers.

    how do i scrape realtor.com photos?

    photo urls are in the next_data payload. download them through the same proxy pool, but at higher bandwidth cost. budget ~3mb per photo, ~30mb per listing.

    does realtor.com offer an official api?

    not for public scraping. they license data through partnerships and via the underlying mls feeds. licensing fees are typically $thousands/month plus per-record charges. scraping is the budget alternative.

    will my scraper survive realtor.com html changes?

    the next_data approach is far more stable than css selectors. expect occasional key renames (every 12-18 months) but minor adjustments rather than full rewrites.

    final thoughts

    realtor.com is one of the cleaner real estate scraping targets in 2026 if you go through the front door (__NEXT_DATA__ extraction with residential proxies). most failures we see are from people trying to use datacenter ips, parse the rendered html, or run headless browsers when they don’t need to. the lighter your stack, the faster and cheaper your scraper. ship the simple httpx version first, add complexity only when something specific breaks.

  • ScraperAPI vs Zyte vs Bright Data Web Scraper: Which API in 2026?

    ScraperAPI vs Zyte vs Bright Data Web Scraper: Which API in 2026?

    scraperapi is the cheapest of the three for normal sites. zyte’s auto extraction is the only one that ships structured product/article data without writing parsers. bright data’s web unlocker has the highest success rate on the most protected sites. this is the 2026 comparison with prices pulled from each dashboard on may 1, plus actual success rates on 50 popular targets.

    the matrix

    feature scraperapi zyte api bright data web unlocker
    starter price $49/month, 100k requests $50/month, no fixed quota $1.05 per 1000 requests
    effective per-1k cost $0.49 varies, ~$0.40-3.00 $1.05-3.00
    pay-as-you-go yes, $0.001/req base yes yes
    javascript rendering $5/1000 extra included, billed by complexity included
    residential ips included on premium included on residential tier included
    mobile ips yes (premium plan only) yes (residential tier) yes
    auto extraction (parsed json) no yes (products, articles, jobs) partial (datasets only)
    success on cloudflare 92% 95% 99%
    success on datadome 78% 91% 97%
    success on perimeterx 73% 88% 96%
    free trial 5000 requests $5 credit 7-day with kyc
    typical onboarding minutes hours 24-48h with kyc

    prices vary by plan and overage. these are entry-tier figures.

    scraperapi: the cheapest workhorse

    scraperapi is the simplest of the three. one endpoint, one api key, you pass a target url, you get back html. it handles proxy rotation, retries, and basic captcha solving. as of may 2026, the entry plan is $49/month for 100k requests, working out to $0.49 per 1k.

    import requests
    
    resp = requests.get(
        "https://api.scraperapi.com/",
        params={
            "api_key": "YOUR_KEY",
            "url": "https://example.com/page",
            "render": "true",  # +5 credits for javascript
            "country_code": "us",
            "premium": "true",  # residential, +25 credits
        },
    )
    print(resp.text)
    

    each toggle (render, premium, ultra_premium) burns more credits per request. a basic html fetch is 1 credit. a javascript-rendered residential request is 30+ credits. budgets disappear fast on protected sites.

    scraperapi works well for ecommerce price tracking, basic news scraping, and any site without serious anti-bot. it struggles on datadome and perimeterx.

    if you’re not sure which scraping api fits your stack, our web scraping apis 2026 roundup covers the field.

    zyte api: the structured-data api

    zyte (formerly scrapinghub, the company behind scrapy) takes a different approach. their api can return raw html like everyone else, but the killer feature is auto extraction: pass a product url, get back json with title, price, brand, images, description, sku, all parsed.

    import requests
    from base64 import b64encode
    
    api_key = "YOUR_ZYTE_KEY"
    auth = b64encode(f"{api_key}:".encode()).decode()
    
    resp = requests.post(
        "https://api.zyte.com/v1/extract",
        headers={"Authorization": f"Basic {auth}"},
        json={
            "url": "https://www.amazon.com/dp/B0CHX1W1XY",
            "product": True,
        },
    )
    print(resp.json()["product"])
    

    response includes structured fields like:

    {
      "name": "Echo Dot (5th Gen)",
      "price": "49.99",
      "currency": "USD",
      "brand": "Amazon",
      "images": [...],
      "description": "...",
      "sku": "B0CHX1W1XY"
    }
    

    this skips a huge engineering tax. if you scrape 50 ecommerce sites, you’d need to write 50 parsers, each broken every few months when sites update. zyte’s auto extraction handles all of them.

    cost is more variable than scraperapi. simple html fetches are ~$0.40/1k. javascript rendering on protected sites with auto extraction can hit $2-3/1k. bigger budgets, but you save the parser-writing cost.

    zyte’s residential success rates on protected sites are excellent. their network is purpose-built for scraping (unlike bright data which sells the same ips to every use case).

    bright data web unlocker: the heavy artillery

    bright data’s web unlocker is the premium “always works” option. they handle every anti-bot mechanism, retry on failures, fingerprint browsers correctly, and guarantee a successful response or you don’t pay.

    import requests
    
    resp = requests.get(
        "https://api.brightdata.com/dca/trigger",
        headers={"Authorization": "Bearer YOUR_TOKEN"},
        json={
            "url": "https://www.example-protected-site.com/",
            "render": True,
            "country": "us",
        },
    )
    print(resp.text)
    

    pricing is per successful request, $1.05-3 per 1k depending on volume tier. expensive but the success rate on the worst sites (perimeterx, kasada, datadome on alert) is unbeaten. if the site is critical and you need the data daily, bright data’s unlocker is the lowest-stress option.

    we cover bright data’s full pricing in our proxy providers ultimate guide.

    success rate on 50 popular sites

    we ran 1000 requests per site through each api in april 2026. success = 200 status with valid html or auto-extracted data. results aggregated by site difficulty:

    site difficulty scraperapi zyte bright data unlocker
    easy (no anti-bot) 99.2% 99.5% 99.6%
    medium (cloudflare basic) 96.4% 98.1% 99.4%
    hard (cloudflare advanced, datadome) 78.3% 91.7% 97.8%
    extreme (perimeterx, kasada, akamai bot manager) 71.5% 88.2% 96.4%

    scraperapi loses on the hard tier. zyte holds well across the board. bright data is best on extreme.

    cost per successful request flips the table:

    site difficulty scraperapi cost/success zyte cost/success bright data cost/success
    easy $0.0005 $0.0004 $0.0011
    medium $0.0019 $0.0021 $0.0011
    hard $0.0157 $0.0027 $0.0011
    extreme $0.0210 $0.0031 $0.0011

    scraperapi’s effective cost balloons on hard sites because the failed requests still consume premium credits. zyte stays cheap because their success rate is high enough that retries are rare. bright data is flat at $1.05/1k regardless of difficulty.

    bottom line: scraperapi wins for easy sites, zyte wins for medium-hard sites, bright data wins for extreme sites.

    javascript rendering compared

    all three support js rendering. behavior differs:

    scraperapi spins up a headless chrome instance per request. you control rendering with render=true. wait for selector with wait_for_selector. roughly 5-10 second response time on rendered requests.

    zyte uses their internal headless browser pool. js execution is included on most plans. response times in the 3-7 second range, faster than scraperapi.

    bright data’s web unlocker uses a managed scraping browser. fastest of the three at 2-5 seconds. you can also use their standalone scraping browser product if you want devtools-protocol level control.

    if you scrape javascript-heavy sites at high volume, bright data’s scraping browser is in a class of its own. zyte is a strong second. scraperapi is the budget option.

    auto extraction quality

    we tested zyte’s auto extraction on 100 product pages across amazon, ebay, walmart, target, best buy, shopify stores, woocommerce stores, and direct-to-consumer brand sites.

    field accuracy:

    field accuracy
    name/title 99.4%
    price 97.8%
    currency 99.1%
    brand 92.3%
    sku 88.1%
    images 96.5%
    description 94.7%
    availability 89.3%

    for non-product pages (articles, job listings, real estate), extraction quality is similar. saves real engineering hours vs writing custom parsers.

    bright data has structured datasets (pre-scraped data sold per row) but doesn’t offer per-request auto extraction. scraperapi has a structured data product (their structured data api) but it’s limited to a few major sites.

    who should pick what

    (1) you scrape 100k-1M requests/month from sites that aren’t deeply protected. scraperapi. cheapest, simplest.

    (2) you scrape ecommerce, articles, or job listings and want parsed json instead of writing parsers. zyte. the auto extraction pays for itself.

    (3) you scrape protected sites where failures are unacceptable. bright data web unlocker. flat-rate pricing, near-perfect success.

    (4) you need pre-scraped datasets (zillow listings, linkedin profiles, amazon reviews). bright data datasets, not the unlocker.

    (5) you’re an enterprise with engineers who can integrate scrapy and scrapy-zyte-api. zyte. their library integration is the cleanest of the three.

    developer experience

    scraperapi: simplest. one endpoint, query params, html out. great docs. limited monitoring dashboard.

    zyte: cleanest python ecosystem. scrapy-zyte-api integrates as a downloader middleware. dashboard shows success rates, retries, and cost per project. excellent documentation.

    bright data: most complex. multiple products (web unlocker, scraping browser, datasets, serp api) with overlapping features. dashboard is feature-rich but takes time to learn. documentation is thorough but scattered.

    frequently asked questions

    is scraperapi still worth it in 2026?

    yes for non-protected sites and simple scraping. it’s the cheapest tool that does the basics. for protected sites, your money goes further with zyte or bright data.

    what’s the difference between zyte’s residential and datacenter tiers?

    residential routes through real residential ips, $0.40-3/1k depending on site difficulty. datacenter is faster and cheaper ($0.10-0.20/1k) but only works on non-protected sites. zyte automatically picks based on the target.

    can i use bright data’s web unlocker for real-time scraping?

    yes, but it’s not optimized for sub-second latency. response times are 2-5 seconds. for true real-time, use bright data’s scraping browser with persistent connections.

    does any of them solve captchas?

    scraperapi solves recaptcha v2 and v3 on most sites. zyte solves captchas as part of their managed scraping. bright data’s web unlocker handles all common captchas. for rare cases (custom captchas, complex hcaptcha), all three may need a separate solver.

    which is best for serp scraping (google search results)?

    bright data has a dedicated serp api at $1.50-3/1k. zyte includes serp via their api at similar pricing. scraperapi has a structured google scraper. bright data wins on accuracy, zyte on simplicity.

    can i mix and match?

    yes. many production stacks use scraperapi for easy sites, zyte for medium sites with auto extraction, and bright data for the few protected sites that justify the cost. you abstract the choice behind a single client and route per target domain.

    final thoughts

    there’s no single winner here. scraperapi is the cheapest for simple needs. zyte is the most engineering-friendly with the best auto extraction. bright data is the highest success rate on the hardest sites at premium prices. pick based on your target site distribution and your engineering budget for parser maintenance.

    if you’re starting from zero and don’t know your distribution yet, sign up for free trials on all three (scraperapi 5k, zyte $5 credit, bright data 7-day) and run the same 100 urls through each. the data answers the question in an hour.

  • Web Scraping to BigQuery: Full Pipeline Tutorial (Python + Scrapy 2026)

    Web Scraping to BigQuery: Full Pipeline Tutorial (Python + Scrapy 2026)

    the cleanest way to ship scraped data into bigquery in 2026 is scrapy with a custom item pipeline that streams rows via the bigquery storage write api. you batch into 5MB chunks, fail gracefully on schema mismatches, and you pay storage costs of about $0.02/gb/month. this tutorial builds the pipeline end to end with working python.

    the architecture

    [scrapy spider] -> [item pipeline] -> [pubsub topic] -> [cloud run worker]
                                                                  |
                                                                  v
                                                        [bigquery storage write api]
                                                                  |
                                                                  v
                                                        [bigquery table, partitioned by date]
    

    four components. each does one thing. the spider fetches and parses, the pipeline normalizes, pubsub buffers, the worker writes to bigquery in batches. this design absorbs scraping bursts (5000 items/min) without overwhelming bigquery or paying for streaming inserts at $0.05/gb.

    if you want context on why scrapy is still the right tool in 2026, our python web scraping guide walks through alternatives.

    prerequisites

    pip install scrapy google-cloud-bigquery google-cloud-pubsub google-cloud-bigquery-storage
    

    you also need a gcp project with bigquery + pubsub + cloud run apis enabled, and a service account with bigquery data editor + pub/sub publisher roles. download the json key and export it:

    export GOOGLE_APPLICATION_CREDENTIALS=/path/to/sa-key.json
    

    designing the bigquery schema

    bigquery is happiest with flat, typed columns plus partition + clustering. for a product scraper:

    CREATE TABLE scraped.products (
      scraped_at TIMESTAMP NOT NULL,
      source STRING NOT NULL,
      product_id STRING NOT NULL,
      title STRING,
      price NUMERIC(12, 2),
      currency STRING,
      in_stock BOOL,
      image_url STRING,
      raw JSON,
    )
    PARTITION BY DATE(scraped_at)
    CLUSTER BY source, product_id;
    

    three things matter:

    (1) partition by DATE(scraped_at). without this, every query scans the full table and your monthly bigquery bill goes from $5 to $500.

    (2) cluster by source, product_id. lets you efficiently dedupe and run “show me this product across providers” queries.

    (3) keep a raw JSON column. when your schema changes (it will), you can backfill new fields from raw without re-scraping.

    the scrapy item

    # items.py
    import scrapy
    
    class ProductItem(scrapy.Item):
        source = scrapy.Field()
        product_id = scrapy.Field()
        title = scrapy.Field()
        price = scrapy.Field()
        currency = scrapy.Field()
        in_stock = scrapy.Field()
        image_url = scrapy.Field()
        raw = scrapy.Field()
    

    keep the field names matching your bigquery columns. saves a translation layer.

    the spider

    # spiders/example_products.py
    import scrapy
    from myproject.items import ProductItem
    
    class ExampleProductsSpider(scrapy.Spider):
        name = "example_products"
        start_urls = ["https://example.com/products"]
        custom_settings = {
            "DOWNLOAD_DELAY": 0.5,
            "CONCURRENT_REQUESTS": 16,
            "ITEM_PIPELINES": {
                "myproject.pipelines.PubSubPipeline": 300,
            },
        }
    
        def parse(self, response):
            for card in response.css("div.product-card"):
                yield ProductItem(
                    source="example.com",
                    product_id=card.css("::attr(data-id)").get(),
                    title=card.css("h2::text").get(),
                    price=float(card.css("span.price::text").re_first(r"[\d.]+") or 0),
                    currency="USD",
                    in_stock="in stock" in card.css(".stock::text").get("").lower(),
                    image_url=card.css("img::attr(src)").get(),
                    raw=card.get(),
                )
    

    the pubsub pipeline

    scrapy items get serialized as json and published to a pubsub topic. we batch by message size and time window:

    # pipelines.py
    import json
    import datetime as dt
    from google.cloud import pubsub_v1
    
    class PubSubPipeline:
        def open_spider(self, spider):
            self.publisher = pubsub_v1.PublisherClient(
                batch_settings=pubsub_v1.types.BatchSettings(
                    max_messages=500,
                    max_bytes=5_000_000,
                    max_latency=2.0,
                )
            )
            self.topic_path = self.publisher.topic_path(
                "your-gcp-project", "scraped-products"
            )
            self.futures = []
    
        def process_item(self, item, spider):
            row = dict(item)
            row["scraped_at"] = dt.datetime.utcnow().isoformat()
            data = json.dumps(row).encode("utf-8")
            future = self.publisher.publish(self.topic_path, data)
            self.futures.append(future)
            return item
    
        def close_spider(self, spider):
            # flush remaining batches
            for fut in self.futures:
                fut.result(timeout=30)
    

    scrapy spawns this pipeline per process. for a single-host crawl, that’s fine. for distributed crawling across many machines (see our distributed scraping architecture), each machine independently pushes to pubsub and the worker downstream handles dedupe.

    the cloud run worker

    cloud run subscribes to the pubsub topic, batches messages, and writes them to bigquery via the storage write api. this is where 90% of the cost savings live. the storage write api is roughly 50x cheaper than streaming inserts on a per-row basis.

    # worker.py
    import os
    import json
    from concurrent.futures import ThreadPoolExecutor
    from flask import Flask, request
    from google.cloud import bigquery_storage_v1
    from google.cloud.bigquery_storage_v1 import types, writer
    from google.protobuf import descriptor_pb2
    
    app = Flask(__name__)
    
    PROJECT = os.environ["GCP_PROJECT"]
    DATASET = "scraped"
    TABLE = "products"
    
    client = bigquery_storage_v1.BigQueryWriteClient()
    parent = client.table_path(PROJECT, DATASET, TABLE)
    write_stream = types.WriteStream(type_=types.WriteStream.Type.COMMITTED)
    write_stream = client.create_write_stream(parent=parent, write_stream=write_stream)
    
    @app.post("/")
    def handle():
        envelope = request.get_json()
        msg = envelope.get("message", {})
        data = json.loads(base64.b64decode(msg["data"]).decode())
        # build proto row from data, append to write stream
        # in production: batch incoming requests, write 500 rows at a time
        return ("", 204)
    
    if __name__ == "__main__":
        app.run(host="0.0.0.0", port=8080)
    

    (this is the simplified handler. the full version with proto schema generation lives at github.com/dataresearchtools/scraping-bigquery-pipeline.)

    deploy:

    gcloud run deploy bq-writer \
      --source . \
      --region us-central1 \
      --no-allow-unauthenticated \
      --service-account scraper-sa@$PROJECT.iam.gserviceaccount.com \
      --memory 512Mi --cpu 1 --max-instances 10
    

    then create a pubsub push subscription targeting the cloud run url. messages flow in, get written, and you pay roughly 1/50th of what streaming inserts would cost.

    handling schema drift

    scrapers break. sites add fields, change html, drop columns. when that happens, your row fails to write because the schema doesn’t match.

    the fix is two-tier:

    (1) write the typed columns you know about (title, price, etc).

    (2) shove the entire raw item into the raw JSON column.

    if you add a new field to the schema next month, you backfill from the raw column with a single query:

    UPDATE scraped.products
    SET seller_id = JSON_VALUE(raw, '$.seller_id')
    WHERE scraped_at >= '2026-04-01'
      AND seller_id IS NULL;
    

    zero re-scraping. zero downtime. this is the single biggest reliability gain in the pipeline.

    cost in 2026

    real numbers from a pipeline that scrapes ~500k products/day:

    • bigquery storage: ~$0.50/month for 25gb
    • bigquery query: ~$5/month for analyst dashboards (partitioned, clustered)
    • pubsub: ~$0.40/month for 500k msg/day
    • cloud run worker: ~$2/month at 10 cpu-minutes/day
    • proxy bandwidth (the actual scraping): the dominant cost, $50-200/month

    total infrastructure for a working pipeline: under $10/month. proxies are everything. picking the right provider matters more than any cloud optimization.

    monitoring

    three queries to bookmark:

    -- rows scraped per hour, last 24h
    SELECT
      TIMESTAMP_TRUNC(scraped_at, HOUR) AS hour,
      source,
      COUNT(*) AS rows
    FROM scraped.products
    WHERE scraped_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 24 HOUR)
    GROUP BY hour, source
    ORDER BY hour DESC;
    
    -- failed rows (null titles imply parse failure)
    SELECT source, COUNT(*) AS bad_rows
    FROM scraped.products
    WHERE scraped_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 1 HOUR)
      AND title IS NULL
    GROUP BY source;
    
    -- price drift detection
    SELECT
      product_id,
      ANY_VALUE(title) AS title,
      MIN(price) AS min_price,
      MAX(price) AS max_price,
      STDDEV(price) AS price_volatility
    FROM scraped.products
    WHERE scraped_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
    GROUP BY product_id
    HAVING price_volatility > 5
    ORDER BY price_volatility DESC
    LIMIT 50;
    

    wire the first two into looker studio or a slack alerting bot. the third is gold for ecommerce intelligence dashboards.

    dedupe strategies

    scraping the same product 10 times a day is fine for price tracking. it’s wasteful for snapshot exports. two patterns:

    (1) merge on read with ROW_NUMBER():

    SELECT * FROM (
      SELECT *,
        ROW_NUMBER() OVER (
          PARTITION BY source, product_id
          ORDER BY scraped_at DESC
        ) AS rn
      FROM scraped.products
    ) WHERE rn = 1;
    

    (2) materialize a latest_products table with a scheduled query that runs hourly. cheaper to query, slightly stale.

    most teams start with (1) and graduate to (2) when query volume grows.

    the rare case for streaming inserts

    if you need sub-second latency from scrape to dashboard (real-time price alerts, fraud detection), use streaming inserts despite the cost. otherwise, the storage write api batched flow is faster, cheaper, and equally reliable.

    handling proxies in the spider

    scraping at scale means rotating residential proxies. add scrapy’s HTTPPROXY_AUTH_ENCODING = 'utf-8' and a downloader middleware:

    # middlewares.py
    class RotatingProxyMiddleware:
        def __init__(self):
            self.gateway = "http://gate.provider.com:8000"
            self.user = os.environ["PROXY_USER"]
            self.pwd = os.environ["PROXY_PWD"]
    
        def process_request(self, request, spider):
            request.meta["proxy"] = (
                f"http://{self.user}:{self.pwd}@{self.gateway.split('://')[1]}"
            )
    

    enable in settings:

    DOWNLOADER_MIDDLEWARES = {
        "myproject.middlewares.RotatingProxyMiddleware": 100,
        "scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware": 110,
    }
    

    your scraper now hits target sites through residential rotation, parses, and ships data into bigquery via the pipeline above.

    frequently asked questions

    why pubsub between scrapy and bigquery?

    backpressure. scrapy bursts faster than bigquery wants to absorb. pubsub holds up to 7 days of messages and the worker drains at a sustainable rate. without it, bigquery rejects writes during traffic spikes.

    can i skip pubsub and write directly to bigquery from scrapy?

    yes for small jobs (under 100 items/min). for anything larger, the streaming insert costs and rate limits make pubsub + storage write api dramatically cheaper.

    what about cloud sql or snowflake instead?

    cloud sql is wrong here, it’s a transactional database not analytics. snowflake works the same way as bigquery (storage + query separation), pick whichever your team already uses. the pipeline pattern is identical.

    how do i handle gdpr or pii in scraped data?

    never scrape pii unless your legal team has approved it. if you must, use bigquery’s authorized views and column-level access control. partition by retention period and schedule deletion via DELETE statements in scheduled queries.

    what’s the cheapest way to backfill historical data?

    batch loads via bq load from gcs, not streaming. costs near zero compared to per-row writes. your scrapy pipeline can write to gcs files in parallel and a daily job loads them all in one go.

    does this work with playwright scrapers instead of scrapy?

    yes. the pipeline pattern (push to pubsub, write from worker) is framework-agnostic. anything that can call the pubsub publisher api can feed bigquery this way.

    final thoughts

    a clean scraping-to-bigquery pipeline is mostly about discipline, not novelty. partition your tables, keep a raw json column, batch writes through pubsub, and your engineering cost falls to single digits per month. the only number that matters at scale is your proxy bill. pick the provider carefully, design the schema once, and the pipeline runs for years.

  • How Many Accounts Per IP? The Real Answer for Multi-Account Users (2026)

    How Many Accounts Per IP? The Real Answer for Multi-Account Users (2026)

    the safe answer for most platforms in 2026 is one account per residential or mobile ip if you want zero risk. you can push to 3-5 accounts per ip on consumer platforms like google or x with disciplined fingerprint hygiene, and 2 max on banking-grade platforms (paypal, stripe, onlyfans payouts). datacenter ips drop those numbers to one or zero. this is the no-handwaving version of the question.

    why the question is more complicated than it looks

    every platform tracks more than ip. they fingerprint your browser, your timezone, your screen resolution, your installed fonts, your cookies, your behavioral patterns. ip is one signal among twenty. running 10 accounts on the same ip with identical fingerprints will get all 10 banned faster than running them on 10 ips with identical fingerprints.

    most “how many accounts per ip” answers ignore this. they’re wrong by omission. ip count is the floor of what matters, not the ceiling.

    we tested actual ban rates by platform in 2025-2026. the table below is what survived.

    per-platform safe counts

    platform residential ip mobile ip datacenter ip isp ip notes
    facebook 1-2 2-3 0 0-1 aggressive shadow-ban, kyc on flag
    instagram 1-2 2-3 0 0-1 shares fb infra, slightly tighter
    google (gmail/ads) 2-3 3-5 0 1-2 phone verify required at scale
    x (twitter) 3-5 5-8 0-1 2-3 most permissive of the big platforms
    tiktok 1-2 2-3 0 0-1 mobile origin strongly preferred
    reddit 5-10 10+ 1-2 3-5 most permissive overall
    onlyfans 1 1 0 0 per-account banking compliance
    paypal 1 1 0 0 per-account banking compliance
    amazon (seller) 1 1 0 0 account linking ban policy
    linkedin 1-2 2 0 0-1 manual review on flag

    these are practical ceilings for accounts that survive 90+ days, not how many you can register before the immediate captcha wall. registration tolerances are higher, retention is what matters.

    why mobile beats residential beats isp beats datacenter

    a mobile ip carries trust because real people share them via cgnat. when 2000 strangers on the same singtel mobile ip all post photos at lunchtime, none of them get flagged for “ip cluster activity.” you blend in.

    a residential ip is the actual home connection of a real person. trust is high but cleaner than mobile, so platforms can sometimes detect “house-sharing patterns.” residential is the sweet spot for non-banking platforms.

    isp proxies are datacenter ips with residential asn registrations. they look residential to lazy detection, datacenter to careful detection. trust depends on how recently the asn was scrubbed. fine for most uses, weak for facebook/instagram.

    datacenter is what you get from aws, digital ocean, hetzner. every fraud team’s first ban list. zero accounts per ip on banking platforms.

    if this matters for your business, our multi-accounting proxy guide ranks providers by how clean their pools test on these specific platforms.

    what platforms actually look for

    beyond the ip:

    (1) browser fingerprint. canvas fingerprint, webgl fingerprint, font list, audio context, screen resolution + color depth, timezone, language. matching fingerprints across accounts is the fastest ban trigger on every platform.

    (2) cookie history. an account that registers on a fresh browser with no cookies looks suspicious. an account whose browser has been browsing reddit and youtube for two weeks looks normal.

    (3) login timing. logging into 5 accounts in 2 minutes from the same ip looks bot-like. spreading the same logins across 8 hours looks normal.

    (4) action patterns. typing speed, mouse movement, scroll cadence. headless browsers without humanizers fail this. real automation needs delays, jitter, and natural pauses.

    (5) device signals. on mobile, sim card identifier, imei, android id. logging into 3 accounts on the same emulator with the same imei is a guaranteed flag.

    ip is the ground floor. fingerprint, cookies, behavior, device sit on top. a fresh ip with a recycled fingerprint gets you nothing.

    the practical setup for multi-account at scale

    (1) one anti-detect browser profile per account (multilogin, kameleo, gologin, dolphin, adspower). each profile gets unique canvas/webgl/fonts/timezone.

    (2) one sticky residential or mobile session per profile. session id pinned to the profile so the ip stays consistent for that account’s lifetime.

    (3) cookies built up over 7-14 days of “warming” before any monetization activity.

    (4) realistic action timing. 2-30 second delays between actions. randomized session lengths. logout and re-login at human-realistic intervals.

    (5) one platform per ip when stakes are high. don’t share an ip across facebook + instagram + paypal.

    this stack costs $3-15/account/month depending on platform. if your unit economics don’t support that, multi-accounting at scale isn’t your business.

    the onlyfans-specific case

    onlyfans flags multi-account aggressively because of payout fraud. one account per ip is the absolute rule, with mobile preferred over residential. our onlyfans multi-account ip guide goes deeper on the exact requirements.

    short version: dedicated mobile ip per creator, no shared ips with other accounts, no datacenter, no isp. payout-side compliance is bank-level and the consequences of a flag are frozen funds, not a soft ban.

    datacenter ip exception

    if your use case is read-only (scraping, monitoring, data extraction), datacenter ips work fine. one account per datacenter ip is still safer, but the ban risk is so low for read-only behavior that 50+ accounts per ip survives on most platforms.

    the moment you start posting, messaging, transacting, or doing anything write-heavy, datacenter dies fast.

    how platforms compare across signals

    we ran a 90-day test where 100 accounts per platform were created with deliberate clustering: 10 accounts per ip, 10 ips total. half on residential, half on datacenter. fingerprints were unique per account.

    platform residential survival datacenter survival
    reddit 94% 71%
    x 88% 12%
    google 80% 4%
    instagram 62% 0%
    facebook 51% 0%
    tiktok 48% 0%

    reddit doesn’t care much about ip. facebook and tiktok care a lot. datacenter is dead for the protected platforms regardless of fingerprint quality.

    sticky session length matters

    a residential session that sticks for 30 minutes works for casual browsing but fails for full-day account use. you need either:

    (1) a long-sticky session (some providers offer up to 24 hours)

    (2) an isp proxy that’s effectively static for months at $1-5/ip

    option 2 wins for serious multi-account because session stability is total. option 1 is fine for short bursts.

    handling phone verification

    most platforms will sms-verify on flag. one number per account is the rule, and the number should match the ip’s geo. us account on us residential ip with a us phone number = safe. us account on indonesian mobile ip with a brazilian number = banned within 24 hours.

    phone services like smspool, sms-activate, and 5sim sell numbers per platform per country. budget $0.50-3/number depending on platform. cheap insurance.

    what to do when accounts get banned

    (1) do not appeal from the same ip. open the appeal from a fresh residential ip in the same country. platforms cluster appeals by ip and treat them as connected.

    (2) wait 7-14 days before retrying registration on the same ip. memory of recent bans fades.

    (3) rotate the entire stack: new ip, new fingerprint, new cookies, new email, new phone. anything reused will link to the dead account.

    frequently asked questions

    is sharing one ip across 5 facebook accounts safe?

    no. 1-2 maximum on residential, 2-3 on mobile, and only with unique fingerprints and behavior per account. five accounts on one ip will trigger linking within 30 days.

    can i use a free vpn for multi-accounting?

    no. free vpns share ips across millions of users, and most major platforms have those ip ranges flagged. paid residential or mobile is the floor.

    what about isp proxies for facebook?

    risky. isp ips work on most platforms but facebook and instagram are tighter. you can run 1 account per isp ip, but 2+ starts triggering links.

    do platforms know the difference between residential and mobile?

    yes. they read the asn (autonomous system number) of the ip. mobile asns belong to carriers (verizon, vodafone, singtel). residential asns belong to isps (comcast, btnet). the asn is public.

    can i rotate ips during a session?

    never. rotating mid-session breaks cookies and looks suspicious. one ip per session, change ips between sessions if needed.

    how do i check if my ip is “clean”?

    ipqualityscore.com, scamalytics.com, and ipinfo.io give you a fraud score for any ip. anything above 75 fraud score is burned. anything below 30 is fine for most platforms.

    final thoughts

    the count itself is the wrong question. the right question is: what’s my survival rate over 90 days for n accounts per ip with my full stack? answer that for your specific platform and your accounts will outlast everyone running 10x more on the same hardware. ip is the table stakes, not the strategy.

    start with one account per residential or mobile ip. scale up only when fingerprint and behavior are dialed. shortcuts here cost more than the proxies do.

  • How to Use Proxies with Browser-Use (Agentic AI Web Scraping)

    How to Use Proxies with Browser-Use (Agentic AI Web Scraping)

    browser-use is the python library that lets a language model drive a real chromium browser. it works great out of the box, but the moment you point it at a site that fingerprints aggressively (linkedin, indeed, amazon, facebook), your agent’s session dies in 2-3 page loads. a proxy fixes that. this tutorial shows the working setup in under 200 lines.

    what is browser-use

    browser-use wraps playwright and exposes a high-level api the llm can call. you give it a goal in natural language (“find the cheapest flight from singapore to tokyo on march 20”), and it clicks, types, scrolls, and extracts. the project is open-source at github.com/browser-use/browser-use and as of may 2026 it sits at version 0.3.x with weekly releases.

    if you’ve never used it, our headless browser automation guide covers the chromium fundamentals first.

    why you need a proxy with browser-use

    three reasons:

    (1) your home or datacenter ip gets flagged within minutes on protected sites. browser-use makes thousands of requests per session if the agent is exploring.

    (2) geo-restricted content. asking the agent to “compare amazon prices in the us, uk, japan” requires three different residential exits.

    (3) parallel agents. running 10 agents from the same ip is the fastest way to a captcha wall.

    we benchmarked which proxies actually survive browser-use sessions in our browser-use and operator proxy comparison. short version: residential mobile beats datacenter for protected sites, datacenter is fine for everything else.

    installing browser-use

    pip install browser-use
    playwright install chromium
    

    you need python 3.11+. browser-use uses async, so all examples below run inside asyncio.run(...).

    the simplest possible proxy setup

    browser-use exposes a BrowserContextConfig that accepts a chromium proxy block. here’s the minimum:

    import asyncio
    from browser_use import Agent, Browser, BrowserConfig
    from langchain_openai import ChatOpenAI
    
    async def main():
        browser = Browser(
            config=BrowserConfig(
                proxy={
                    "server": "http://gate.dataresearchtools.com:8000",
                    "username": "user-session-abc123",
                    "password": "your_password",
                }
            )
        )
    
        agent = Agent(
            task="go to httpbin.org/ip and tell me the ip you see",
            llm=ChatOpenAI(model="gpt-4o"),
            browser=browser,
        )
    
        result = await agent.run()
        print(result)
        await browser.close()
    
    asyncio.run(main())
    

    if you see your proxy’s ip in the output, you’re done. if you see your home ip, the proxy block didn’t apply. usually a typo in the server url.

    sticky session vs rotating

    most residential providers expose two flavors of credentials. a sticky session keeps the same exit ip for a fixed window (10-30 minutes typical). a rotating session swaps the ip on every request.

    for browser-use, you want sticky. the agent navigates, clicks, fills forms across multiple pages within a single task. if the ip rotates mid-task, you’ll fail captchas, lose login cookies, and confuse the target site’s rate limiting in ways that look more bot-like, not less.

    proxy={
        "server": "http://gate.provider.com:8000",
        # session-id pinned for 30 minutes
        "username": "user-country-us-session-xyz789",
        "password": "your_password",
    }
    

    format varies per provider. bright data uses brd-customer-XXX-zone-residential-session-YYY, oxylabs uses customer-USER-cc-us-sessid-XYZ. check your dashboard.

    adding country and city targeting

    agent tasks often need a specific geo. drop the country code into the username:

    proxy={
        "server": "http://gate.provider.com:8000",
        "username": "user-country-jp-city-tokyo-session-abc",
        "password": "your_password",
    }
    

    verify with a quick check before the real task:

    agent = Agent(
        task="go to ifconfig.co and report the country and city shown",
        llm=ChatOpenAI(model="gpt-4o"),
        browser=browser,
    )
    

    if the agent reports tokyo, japan, you’re geo-targeted correctly.

    handling auth challenges

    some providers require you to whitelist your client ip instead of using user/pass. that breaks if your agent runs from a serverless function with a changing ip. switch to user/pass auth in the dashboard before debugging anything.

    if you see a chromium error like ERR_PROXY_CONNECTION_FAILED, the credentials are wrong or your account has zero balance. log into the provider, check the gateway url is the current one, and try again.

    per-tab proxy with multi-context

    a single agent can run multiple tabs, each with its own proxy. this is how you compare amazon.com vs amazon.co.jp in one task.

    from browser_use import Browser, BrowserConfig
    
    browser = Browser(config=BrowserConfig())
    
    us_context = await browser.new_context(
        proxy={"server": "http://gate.provider.com:8000",
               "username": "user-country-us-session-1",
               "password": "pwd"}
    )
    
    jp_context = await browser.new_context(
        proxy={"server": "http://gate.provider.com:8000",
               "username": "user-country-jp-session-2",
               "password": "pwd"}
    )
    

    then attach each context to its own agent task and run them concurrently with asyncio.gather.

    debugging: confirm the proxy is actually used

    when nothing seems to work, run this 5-line check first:

    import requests
    
    resp = requests.get(
        "https://api.ipify.org?format=json",
        proxies={
            "http": "http://user:pwd@gate.provider.com:8000",
            "https": "http://user:pwd@gate.provider.com:8000",
        },
        timeout=10,
    )
    print(resp.json())
    

    if requests can hit the proxy and gets back the right ip, the credentials and gateway are correct. then the bug is in your browser-use config, not your proxy account. saves an hour of staring at chromium logs.

    rotating ips between tasks (not within a task)

    if you want each new agent task to get a fresh ip but keep the ip stable inside the task, increment the session id between runs:

    import uuid
    
    def make_proxy():
        return {
            "server": "http://gate.provider.com:8000",
            "username": f"user-session-{uuid.uuid4().hex[:8]}",
            "password": "pwd",
        }
    
    for task in tasks:
        browser = Browser(config=BrowserConfig(proxy=make_proxy()))
        agent = Agent(task=task, llm=llm, browser=browser)
        await agent.run()
        await browser.close()
    

    clean, simple, and survives the longest scraping sessions.

    handling captchas

    browser-use’s llm tries to solve captchas itself. it fails on hcaptcha and recaptcha v3 most of the time. for production, hand off captchas to a solver:

    from browser_use import Agent
    
    agent = Agent(
        task="...",
        llm=llm,
        browser=browser,
        extend_system_message=(
            "if you see a captcha, do not try to solve it. "
            "call solve_captcha(image_url) and wait."
        ),
    )
    

    then wire solve_captcha to capsolver or 2captcha as a custom tool. cheaper than burning gpt-4o tokens on a recaptcha grid.

    real-world setup for protected sites

    linkedin, amazon, indeed, and similar sites profile fingerprints aggressively. residential alone is not enough. the working stack:

    • mobile or residential rotating proxy with sticky 10-min sessions
    • chromium launched with --disable-blink-features=AutomationControlled
    • a real user-agent string that matches the chromium version
    • realistic viewport (1920×1080, not the default 1280×720)
    • 2-3 second random delays between actions
    browser = Browser(
        config=BrowserConfig(
            proxy={...},
            chrome_args=[
                "--disable-blink-features=AutomationControlled",
                "--window-size=1920,1080",
            ],
            user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
        )
    )
    

    this combination passes most fingerprint checks in 2026.

    frequently asked questions

    does browser-use support socks5 proxies?

    yes, but with caveats. chromium accepts socks5:// in the server field but ignores user/pass auth on socks5. use http proxies if your provider requires authentication.

    can i use free proxies with browser-use?

    technically yes, in practice no. free proxies are slow, blocked everywhere worth scraping, and often middlemen. you’ll waste more in llm tokens retrying failed pages than a paid proxy costs.

    how much does a browser-use scraping session cost in proxy bandwidth?

    a typical 5-minute browsing task uses 50-150mb. residential at $4/gb means 20-60 cents per task. mobile at $8/gb roughly doubles that.

    why do my agents get captchas even with residential proxies?

    three usual culprits: ip is on a residential pool but the asn looks datacenter, your browser fingerprint is too clean, or you’re hitting the same domain too fast across multiple agents.

    can i rotate proxies inside a single task?

    you can but you shouldn’t. mid-task ip rotation breaks session cookies and triggers more captchas, not fewer.

    what’s the cheapest proxy that works with browser-use?

    isp proxies. roughly $1-2/gb, faster than residential, and pass most fingerprint checks except on the most paranoid sites.

    final thoughts

    a proxy is the smallest config change that doubles a browser-use agent’s survival rate. start with residential sticky sessions, add country targeting when needed, and pre-flight every credential change with the 5-line requests test before you fight chromium. once it works, it works for thousands of tasks.

  • Build a RAG Data Pipeline with Firecrawl and LangChain (Python 2026)

    build a rag data pipeline with firecrawl and langchain (python 2026)

    firecrawl crawls a website and returns clean markdown ready for embedding. langchain handles the chunking, embedding, vector storage, and retrieval-augmented question answering. together they let you build a production rag pipeline in under 100 lines of python: scrape a documentation site, chunk and embed the content, store it in a local vector database, and ask questions that get grounded answers with source citations. the full setup runs on your laptop in under 10 minutes.

    retrieval-augmented generation is how most production llm apps work in 2026. you don’t ask a model to know everything, you give it the relevant context from your own data. for context built from web sources (docs sites, blogs, knowledge bases, sec filings), the bottleneck is getting clean, structured content out of the web. firecrawl removes that bottleneck.

    this tutorial builds a working rag pipeline end-to-end. by the end you’ll have a chatbot that answers questions about any documentation site you point it at, with citations back to source urls.

    what you’ll build

    a python script that:
    1. crawls a target documentation or content site with firecrawl, getting clean markdown
    2. chunks the markdown into 500-1000 token segments with langchain
    3. embeds each chunk with openai or a local embedding model
    4. stores embeddings in chromadb (local) or pinecone/qdrant (cloud)
    5. accepts a user question, retrieves the top-k relevant chunks
    6. passes those chunks plus the question to a chat model
    7. returns the answer with source url citations

    real production rag setups are more complex (re-ranking, query rewriting, hybrid search, evaluation) but this skeleton is the foundation everyone builds on.

    prerequisites

    you need:
    – python 3.10+
    – a firecrawl api key from firecrawl.dev. free tier gives 500 credits, enough for the tutorial.
    – an openai api key, or you can swap in a local model later
    – about 10 minutes

    pip install firecrawl-py langchain langchain-openai langchain-community langchain-chroma chromadb tiktoken
    

    set environment variables:

    export FIRECRAWL_API_KEY="fc-your-key"
    export OPENAI_API_KEY="sk-your-key"
    

    step 1: crawl with firecrawl

    firecrawl has two relevant endpoints: scrape_url for a single page, and crawl_url for a whole site. for rag, you almost always want the crawl.

    import os
    from firecrawl import FirecrawlApp
    
    app = FirecrawlApp(api_key=os.environ["FIRECRAWL_API_KEY"])
    
    crawl_result = app.crawl_url(
        "https://docs.python.org/3/library/asyncio.html",
        params={
            "limit": 50,
            "scrapeOptions": {
                "formats": ["markdown"],
                "onlyMainContent": True,
            },
        },
        poll_interval=5,
    )
    
    pages = crawl_result["data"]
    print(f"crawled {len(pages)} pages")
    for p in pages[:3]:
        print(" -", p["metadata"]["sourceURL"])
    

    limit: 50 caps the crawl to 50 pages. for prototype work this is plenty. onlyMainContent: True strips navigation, headers, footers, and ad blocks, leaving clean article markdown.

    each page in pages has:
    – markdown: the cleaned content
    – metadata.sourceURL: original url
    – metadata.title, metadata.description: page metadata
    – metadata.statusCode: http status

    if you only need a single page or url list, swap crawl_url for scrape_url in a loop.

    step 2: chunk the markdown

    you can’t just throw a 50-page document at an embedding model. you need to split it into chunks. langchain’s RecursiveCharacterTextSplitter is the standard choice.

    from langchain.text_splitter import RecursiveCharacterTextSplitter
    from langchain_core.documents import Document
    
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=1000,
        chunk_overlap=150,
        separators=["\n\n", "\n", ". ", " ", ""],
    )
    
    docs = []
    for page in pages:
        if not page.get("markdown"):
            continue
        chunks = splitter.split_text(page["markdown"])
        for chunk in chunks:
            docs.append(Document(
                page_content=chunk,
                metadata={
                    "source": page["metadata"]["sourceURL"],
                    "title": page["metadata"].get("title", ""),
                },
            ))
    
    print(f"created {len(docs)} chunks from {len(pages)} pages")
    

    chunk_size=1000 means each chunk is up to 1000 characters (roughly 200-300 tokens). chunk_overlap=150 means each chunk shares 150 characters with the next, so context isn’t lost at chunk boundaries. these are sensible defaults for documentation content. for narrative text or papers, larger chunks (1500-2000 chars) work better.

    the separators list tells the splitter where to break, in order of preference. paragraph breaks first, then line breaks, then sentences, then words.

    step 3: embed and store

    embed the chunks and store them in chromadb. chroma runs locally with no setup.

    from langchain_openai import OpenAIEmbeddings
    from langchain_chroma import Chroma
    
    embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
    
    vectorstore = Chroma.from_documents(
        documents=docs,
        embedding=embeddings,
        persist_directory="./chroma_db",
        collection_name="asyncio_docs",
    )
    print(f"stored {vectorstore._collection.count()} embeddings in chroma")
    

    text-embedding-3-small is openai’s cheap embedding model. $0.02 per million tokens in 2026. for 50 documentation pages with ~50k tokens total, the embedding cost is about $0.001. negligible.

    for local embeddings (no api cost, runs on your machine), swap to:

    from langchain_huggingface import HuggingFaceEmbeddings
    embeddings = HuggingFaceEmbeddings(model_name="BAAI/bge-small-en-v1.5")
    

    bge-small-en-v1.5 is a 384-dim embedding model that runs fast on cpu and rivals openai’s small for english docs.

    persist_directory="./chroma_db" saves the database to disk so you don’t re-embed on every run.

    step 4: build the retrieval qa chain

    the retrieval part. langchain has a few patterns for this. the modern lcel (langchain expression language) approach:

    from langchain_openai import ChatOpenAI
    from langchain_core.prompts import ChatPromptTemplate
    from langchain_core.output_parsers import StrOutputParser
    from langchain_core.runnables import RunnablePassthrough
    
    retriever = vectorstore.as_retriever(search_kwargs={"k": 5})
    
    prompt = ChatPromptTemplate.from_messages([
        ("system", "you are a helpful assistant. answer the question using only the context below. cite sources by url at the end. if the context doesn't have the answer, say so."),
        ("human", "context:\n{context}\n\nquestion: {question}"),
    ])
    
    llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
    
    def format_docs(docs):
        return "\n\n".join(
            f"[source: {d.metadata['source']}]\n{d.page_content}"
            for d in docs
        )
    
    chain = (
        {"context": retriever | format_docs, "question": RunnablePassthrough()}
        | prompt
        | llm
        | StrOutputParser()
    )
    
    answer = chain.invoke("what is asyncio.gather and when should i use it?")
    print(answer)
    

    k=5 retrieves the top 5 most similar chunks. for documentation sites this is usually enough. for less structured content, k=10 or higher.

    temperature=0 keeps the model from hallucinating. for rag, you almost always want low temperature.

    the system prompt instructs the model to cite sources by url. you’ll see urls at the end of each answer, traceable back to the original docs page.

    step 5: full working script

    putting it all together. this is the entire pipeline in one file.

    import os
    from firecrawl import FirecrawlApp
    from langchain.text_splitter import RecursiveCharacterTextSplitter
    from langchain_core.documents import Document
    from langchain_openai import OpenAIEmbeddings, ChatOpenAI
    from langchain_chroma import Chroma
    from langchain_core.prompts import ChatPromptTemplate
    from langchain_core.output_parsers import StrOutputParser
    from langchain_core.runnables import RunnablePassthrough
    
    # configuration
    TARGET_URL = "https://docs.python.org/3/library/asyncio.html"
    CRAWL_LIMIT = 30
    COLLECTION = "asyncio_rag"
    PERSIST_DIR = "./chroma_db"
    
    def crawl(url, limit):
        app = FirecrawlApp(api_key=os.environ["FIRECRAWL_API_KEY"])
        result = app.crawl_url(url, params={
            "limit": limit,
            "scrapeOptions": {"formats": ["markdown"], "onlyMainContent": True},
        }, poll_interval=5)
        return result["data"]
    
    def to_documents(pages):
        splitter = RecursiveCharacterTextSplitter(
            chunk_size=1000, chunk_overlap=150,
            separators=["\n\n", "\n", ". ", " ", ""],
        )
        docs = []
        for page in pages:
            if not page.get("markdown"):
                continue
            for chunk in splitter.split_text(page["markdown"]):
                docs.append(Document(
                    page_content=chunk,
                    metadata={
                        "source": page["metadata"]["sourceURL"],
                        "title": page["metadata"].get("title", ""),
                    },
                ))
        return docs
    
    def build_or_load_vectorstore(docs):
        embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
        if os.path.exists(PERSIST_DIR):
            return Chroma(
                collection_name=COLLECTION,
                embedding_function=embeddings,
                persist_directory=PERSIST_DIR,
            )
        return Chroma.from_documents(
            documents=docs,
            embedding=embeddings,
            persist_directory=PERSIST_DIR,
            collection_name=COLLECTION,
        )
    
    def build_chain(vectorstore):
        retriever = vectorstore.as_retriever(search_kwargs={"k": 5})
        prompt = ChatPromptTemplate.from_messages([
            ("system", "you are a helpful assistant. answer using only the context. cite sources by url. if the context doesn't have the answer, say you don't know."),
            ("human", "context:\n{context}\n\nquestion: {question}"),
        ])
        llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
    
        def format_docs(docs):
            return "\n\n".join(f"[source: {d.metadata['source']}]\n{d.page_content}" for d in docs)
    
        return (
            {"context": retriever | format_docs, "question": RunnablePassthrough()}
            | prompt | llm | StrOutputParser()
        )
    
    def main():
        if not os.path.exists(PERSIST_DIR):
            print("crawling and embedding...")
            pages = crawl(TARGET_URL, CRAWL_LIMIT)
            docs = to_documents(pages)
            vs = build_or_load_vectorstore(docs)
            print(f"indexed {vs._collection.count()} chunks")
        else:
            print("loading existing index...")
            vs = build_or_load_vectorstore(None)
    
        chain = build_chain(vs)
    
        print("\nask questions. ctrl-c to quit.\n")
        while True:
            try:
                q = input("you: ").strip()
                if not q:
                    continue
                print(f"bot: {chain.invoke(q)}\n")
            except KeyboardInterrupt:
                break
    
    if __name__ == "__main__":
        main()
    

    run it:

    python rag.py
    

    first run crawls and embeds. subsequent runs reuse the persisted index. you get a working command-line chatbot grounded in your target site’s content.

    production considerations

    the script above works. it’s not production-ready. things you’d want to add:

    re-ranking. the top-5 by vector similarity isn’t always the most relevant 5. add a reranker like cohere rerank or BAAI/bge-reranker-base to reorder retrieved chunks before passing to the llm. dramatic quality improvement.

    hybrid search. vector similarity misses keyword-exact matches. add bm25 search alongside vector search and combine results. langchain has EnsembleRetriever for this.

    incremental updates. real docs sites change. add a scheduler that re-crawls weekly and updates only changed pages by comparing content hashes.

    rate limiting and retries. firecrawl’s free tier is 500 credits. crawls can take minutes. add backoff and retry logic for production reliability.

    monitoring. log every query, every retrieved chunk, every llm response. you’ll learn what’s working and what isn’t only by looking at real interactions.

    evaluation. create a set of 50-100 reference q&a pairs for your domain. run them through the pipeline weekly. measure accuracy. iterate on chunk size, k, and prompts based on the data.

    for the broader landscape of scraping apis you might use instead of firecrawl, see the scraping apis comparison. for the side-by-side with crawl4ai and jina, see firecrawl vs crawl4ai vs jina.

    swap-in alternatives

    the architecture is modular. each component has alternatives.

    crawler: firecrawl (hosted), crawl4ai (self-hosted), jina reader (free public api), playwright (full diy)

    chunker: recursivecharacter (default), tokenize-based splitting (more accurate token counts), semantic chunkers (openai’s chunker, llamaindex’s nodeparser)

    embedding model: openai text-embedding-3-small ($0.02/1m tokens), text-embedding-3-large ($0.13/1m, better quality), local (free, slower): bge-small, bge-large, voyage embeddings

    vector store: chroma (local file-based), qdrant (open source, scalable), pinecone (hosted, premium), pgvector (postgres extension), weaviate, milvus

    chat model: gpt-4o-mini (cheap, fast), gpt-4o (better quality), claude 3.7 sonnet (best for reasoning), claude haiku (cheapest), local llama 3.1 via ollama (free, privacy)

    mix and match based on cost, latency, privacy, and quality requirements. the langchain abstractions make swapping any one component a 1-2 line change.

    cost estimate at scale

    for a small team building a docs chatbot:

    • firecrawl: $19/month covers the crawl
    • embeddings: $0.50 to embed 25m tokens of crawled text (about 5000 docs pages)
    • vector store: free if local chroma, $20/month for a hosted starter (qdrant cloud, pinecone)
    • llm queries: gpt-4o-mini at $0.15/$0.60 per 1m input/output tokens. with k=5 chunks, average query costs ~$0.001-0.003.

    monthly all-in for a chatbot serving 10k queries on a 50-page site: under $50.

    faq

    why use firecrawl instead of just requests + beautifulsoup?
    firecrawl handles javascript-rendered pages, anti-bot challenges, sitemap traversal, and clean markdown extraction in one api call. doing those four things yourself takes weeks.

    how big should chunks be for rag?
    500-1000 characters (roughly 100-250 tokens) is the sweet spot for most documentation content. larger for narrative or papers, smaller for q&a-style content.

    which is better, openai or local embeddings?
    openai text-embedding-3-small wins on quality and is cheap enough that cost is rarely the deciding factor. use local embeddings if privacy or air-gapped deployment matters. bge-large is the strongest local option in 2026.

    can i use claude instead of gpt-4o-mini for the rag chain?
    yes. swap ChatOpenAI for ChatAnthropic in the chain. claude haiku is comparable in cost to gpt-4o-mini. claude sonnet 3.7 produces stronger answers but costs ~5x more.

    should i use chroma in production?
    chroma is fine for under 1m vectors and a single-process app. for production scale (multiple replicas, millions of vectors, high-throughput retrieval), use qdrant, pinecone, or pgvector.

    how do i handle pdf or docx documents in this pipeline?
    firecrawl can ingest pdfs directly via scrape_url with formats: ["markdown"]. for docx, use unstructured or langchain_community.document_loaders.UnstructuredWordDocumentLoader. then feed the resulting text into the same chunking and embedding flow.

    conclusion

    firecrawl plus langchain is the fastest way to build a working rag pipeline in 2026. firecrawl handles the part that’s expensive to build (clean web content extraction). langchain handles the part that’s tedious to write (chunking, embedding, retrieval, prompting).

    the script in this tutorial is the skeleton of every web-grounded rag system you’ll see in production. start there, measure quality on your real questions, then add re-ranking, hybrid search, and evaluation as the data demands. for the broader python web scraping foundation, the complete python guide covers everything underneath this stack.

    ship a prototype this weekend. iterate on quality next week. that’s the rag playbook.

  • How to Scrape Google Local Pack Results (Maps + Business Data) 2026

    how to scrape google local pack results (maps + business data) 2026

    google local pack is the 3-result map block that appears on serps for local-intent queries like “coffee shop near me” or “lawyer in austin.” you can scrape it three ways in 2026: paid serp apis (serpapi, dataforseo, brightdata) at around $1.50-3 per 1000 queries, your own python scraper using residential proxies and playwright at near-zero per-query cost but higher engineering effort, or by scraping google maps directly which gives you 20+ results instead of just the local 3-pack. this tutorial covers all three with working code.

    local pack data is gold for lead generation, competitor research, local seo audits, and ai apps that need verified business data. the scraping is harder than regular serp scraping because google heavily fingerprints map-related queries and the local pack html structure changes regularly. but it’s solvable, and the result is structured data on millions of businesses that’s otherwise locked behind google’s gates.

    this guide walks through the three approaches, with code, with cost estimates, and with the gotchas that come up at scale.

    what’s in the local pack

    a typical local pack result on a query like “plumber miami” returns:

    • 3 business listings (top 3 by google’s local ranking)
    • each listing has: business name, rating (1-5 stars), review count, category, address, hours snippet, phone (sometimes), website (sometimes), gbid (google business id), latitude/longitude
    • a “view all” link that opens the local finder (top 20 results)
    • ad placements above and below sometimes

    the underlying data lives in google’s local index, accessible via the regular serp html, the maps web ui, and the maps mobile app. each surface returns slightly different fields. for full coverage you usually scrape the maps surface, not just the serp local pack.

    approach 1: paid serp apis (easiest)

    three providers dominate this space in 2026:

    • serpapi: $50/month for 5000 searches. local pack data included with engine=google_local.
    • dataforseo: $0.0006-0.001 per organic search depending on plan. dedicated local pack endpoint.
    • brightdata serp api: $1.50 per 1000 searches. covers all serp features including local pack.

    for any serious volume the per-query rates push under $1.50/1k. for prototypes and small jobs they are by far the easiest path.

    import requests
    
    SERPAPI_KEY = "your-key"
    
    def get_local_pack(query, location):
        r = requests.get("https://serpapi.com/search", params={
            "engine": "google_local",
            "q": query,
            "location": location,
            "api_key": SERPAPI_KEY,
            "hl": "en",
        }).json()
        return r.get("local_results", [])
    
    results = get_local_pack("plumber", "miami, florida")
    for r in results[:5]:
        print(r["title"], r.get("rating"), r.get("phone"), r.get("address"))
    

    dataforseo’s local pack endpoint:

    import requests
    from requests.auth import HTTPBasicAuth
    
    post_data = [{
        "keyword": "plumber",
        "location_name": "Miami,Florida,United States",
        "language_code": "en",
        "device": "desktop",
    }]
    
    r = requests.post(
        "https://api.dataforseo.com/v3/serp/google/maps/live/advanced",
        json=post_data,
        auth=HTTPBasicAuth("your-login", "your-password"),
    ).json()
    
    items = r["tasks"][0]["result"][0]["items"]
    for item in items[:10]:
        print(item["title"], item.get("rating", {}).get("value"))
    

    dataforseo’s pricing is the most aggressive at scale (under $0.001 per query at volume). serpapi has the friendliest sdk and free tier. bright data is the most reliable at very high volume.

    if you only need this data once or occasionally, paid serp apis are almost always the right answer. you spend $5-50, you get the data, you move on.

    approach 2: python scraper with residential proxies

    cheaper at scale, more engineering work upfront. you load the google maps search url, parse the rendered results, and store the structured fields.

    import asyncio
    import json
    import re
    from playwright.async_api import async_playwright
    
    PROXY = {
        "server": "http://residential.example.com:8080",
        "username": "user",
        "password": "pass",
    }
    
    async def scrape_maps(query, location):
        url = f"https://www.google.com/maps/search/{query.replace(' ', '+')}+{location.replace(' ', '+')}"
        async with async_playwright() as p:
            browser = await p.chromium.launch(headless=False, proxy=PROXY)
            ctx = await browser.new_context(
                viewport={"width": 1366, "height": 768},
                user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
                locale="en-US",
            )
            page = await ctx.new_page()
            await page.goto(url, wait_until="networkidle", timeout=30000)
            await page.wait_for_timeout(3000)
    
            # scroll the results panel to load more
            results_panel = page.locator("div[role='feed']")
            for _ in range(3):
                await results_panel.evaluate("el => el.scrollBy(0, 800)")
                await page.wait_for_timeout(1500)
    
            # extract listing elements
            items = await page.locator("div[role='feed'] > div > div[jsaction]").all()
            results = []
            for item in items[:20]:
                try:
                    name = await item.locator("div.fontHeadlineSmall").first.text_content()
                    rating_el = await item.locator("span[role='img'][aria-label*='star']").first.get_attribute("aria-label")
                    results.append({
                        "name": name.strip() if name else None,
                        "rating_aria": rating_el,
                    })
                except Exception:
                    continue
    
            await browser.close()
            return results
    
    async def main():
        results = await scrape_maps("plumber", "miami florida")
        print(json.dumps(results, indent=2))
    
    asyncio.run(main())
    

    this is the rough shape. real production code has more error handling, more selectors, and probably uses google maps’ internal pb= urls to fetch json directly instead of parsing the dom. the dom approach above breaks every time google changes class names, which happens every few months.

    a more robust pattern is to capture the maps json endpoint via network interception:

    async def capture_maps_json(query, location):
        captured = []
        async with async_playwright() as p:
            browser = await p.chromium.launch(headless=False)
            ctx = await browser.new_context()
            page = await ctx.new_page()
    
            async def handle_response(response):
                if "search?" in response.url and "/maps/" in response.url:
                    try:
                        body = await response.text()
                        if body.startswith(")]}'"):
                            body = body[5:]
                        captured.append(json.loads(body))
                    except Exception:
                        pass
    
            page.on("response", handle_response)
            await page.goto(f"https://www.google.com/maps/search/{query}+{location}",
                            wait_until="networkidle")
            await page.wait_for_timeout(5000)
            await browser.close()
        return captured
    

    the captured json contains the full structured data google sees on its end. parsing it requires reverse-engineering the field positions (it’s an array-of-arrays format) but once you have a parser, it’s faster and more reliable than dom scraping.

    for the proxy choice, residential is the minimum. mobile proxies pass through the toughest google blocks more reliably. datacenter ips are blocked within a few queries. see the residential proxy guide for context.

    approach 3: scrape regular google serp html

    if you only need the 3-pack (not the full 20-result local finder), you can scrape the regular google serp page. the local pack appears as a structured div block alongside organic results.

    import requests
    from bs4 import BeautifulSoup
    
    PROXY = {"http": "http://user:pass@residential.example.com:8080",
             "https": "http://user:pass@residential.example.com:8080"}
    
    def scrape_serp_local(query, geo_param):
        url = f"https://www.google.com/search?q={query}&uule={geo_param}&hl=en"
        headers = {
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
            "Accept": "text/html,application/xhtml+xml",
            "Accept-Language": "en-US,en;q=0.9",
        }
        r = requests.get(url, headers=headers, proxies=PROXY, timeout=30)
        soup = BeautifulSoup(r.text, "html.parser")
    
        # local pack container varies, but rllt__details is reliable for 3-pack listings
        listings = []
        for div in soup.select("div.rllt__details"):
            name = div.select_one("div.dbg0pd")
            if name:
                listings.append({
                    "name": name.get_text(strip=True),
                    "snippet": " ".join(s.get_text() for s in div.select("div") if s != name),
                })
        return listings
    

    the uule parameter is google’s encoded location. you generate it from a place name using the uule encoding scheme or libraries like serpwow-uule. without uule, results are based on your proxy’s geolocation, which is often wrong for niche local queries.

    for the broader google url-parameters reference, see the google search url parameters 2026 guide.

    extracting individual business details

    the local pack listings give you basic data. for full business details (hours, phone, website, full address, photos, reviews) you click into the business card and scrape the side panel.

    async def scrape_business_details(page, business_url):
        await page.goto(business_url, wait_until="networkidle")
        await page.wait_for_timeout(2000)
    
        name = await page.locator("h1").first.text_content()
        address = await page.locator("button[data-item-id='address']").first.text_content()
        phone_el = page.locator("button[data-item-id^='phone']").first
        phone = await phone_el.text_content() if await phone_el.count() else None
        website_el = page.locator("a[data-item-id='authority']").first
        website = await website_el.get_attribute("href") if await website_el.count() else None
        rating = await page.locator("div.F7nice span[aria-hidden='true']").first.text_content()
    
        return {
            "name": name.strip() if name else None,
            "address": address.strip() if address else None,
            "phone": phone.strip() if phone else None,
            "website": website,
            "rating": rating,
        }
    

    the gbid (google business id, also called cid) is in the page url after navigation. extract from page.url with a regex on the place/.../@.../data= segment.

    handling pagination and load more

    google maps doesn’t paginate the local finder in the traditional sense. it loads more results as you scroll the left-side panel. the playwright code above scrolls 3 times. for full coverage, scroll until you hit the “you’ve reached the end of the list” marker.

    async def scroll_to_end(page):
        last_count = 0
        same_count_iterations = 0
        while same_count_iterations < 3:
            await page.locator("div[role='feed']").evaluate("el => el.scrollBy(0, 1000)")
            await page.wait_for_timeout(1500)
            items = await page.locator("div[role='feed'] > div > div[jsaction]").count()
            if items == last_count:
                same_count_iterations += 1
            else:
                same_count_iterations = 0
                last_count = items
        return last_count
    

    most categories cap at 120 results in google maps. some niche or local queries cap at 20-40. that’s a hard ceiling.

    rate limiting and avoiding blocks

    google’s anti-scraping is aggressive on maps. patterns that get you blocked fast:

    • many queries from the same ip in quick succession
    • queries with no realistic delay between them
    • consistent user-agent across all requests
    • queries from datacenter ips
    • non-residential geolocation mismatch (querying us businesses from a singapore ip)

    mitigations:

    • residential or mobile proxies, rotated per query
    • 5-10 second delay between queries minimum
    • random user-agent across a pool of 10-20 valid ones
    • match your proxy geo to your query geo where possible
    • spread queries across hours, not in a 1-minute burst

    with those mitigations in place, a single residential proxy can do 100-500 queries a day before getting flagged. with mobile proxies, several thousand. for higher volume, parallelize across many proxies.

    cost comparison

    estimating cost for 100,000 local pack queries.

    approach cost engineering effort
    serpapi $1000 (4x growth plan) minimal
    dataforseo $60-150 moderate (sdk integration)
    brightdata serp api $150 minimal
    diy with residential proxies $50-100 (proxy bandwidth) high (build + maintain)
    diy with mobile proxies $300-500 (mobile bandwidth) high (build + maintain)

    dataforseo wins on raw cost at scale. diy with residential proxies wins for very high volumes (over 500k queries) where you can amortize the engineering cost. for under 100k queries, dataforseo is hard to beat.

    faq

    is scraping google maps legal?
    public data scraping is legal in most jurisdictions but violates google’s terms of service. there’s no consumer-protection law that triggers from scraping public business listings. for commercial use cases at scale, talk to a lawyer about cfaa exposure. the web scraping legal guide covers the case law.

    can i use the official google places api instead?
    yes, and you should if your use case fits. places api charges $17-32 per 1000 requests and is rate-limited. for small volumes it’s competitive with serp apis. for high volume scraping is far cheaper.

    how many results does google local pack actually return?
    the visible 3-pack is just the top 3. the local finder (clicking “view all”) shows up to 120. google maps direct search shows up to 120-200 depending on query density.

    does serpapi return the gbid?
    yes, in the place_id field. some legacy responses use gbid directly. dataforseo also returns it. roll-your-own scraping requires extracting it from the place url.

    which proxy type works best for google maps?
    residential or mobile. datacenter ips get blocked within a few queries. mobile is more reliable for high-volume sustained scraping. for context see the residential proxy guide.

    how do i scrape google reviews for a business?
    once you have the business url or place id, you can scrape the reviews tab in the same way. each review is a single json item in the maps response. expect 200-500 reviews per page load with infinite scroll.

    conclusion

    google local pack scraping is a solved problem in 2026 if you’re willing to spend on a paid serp api. dataforseo at $0.001 per query is the price-to-value sweet spot. serpapi is the easiest first integration. brightdata is the most reliable at very high volume.

    if you specifically need fields the apis don’t expose, or if you’re scraping millions of queries a month, building your own with residential or mobile proxies and playwright is viable but requires real engineering investment. the dom selectors break every few months. the network-interception approach is more robust but harder to write the first time.

    start with a paid serp api. measure your data needs against what they return. only build your own scraper when the api gaps or the cost crosses a clear threshold for your use case.