# Web Scraping in Databricks: Land Any Website in a Delta Table with Scrape.do > Source: https://scrape.do/blog/databricks-web-scraping/ Published: 2026-07-14 · Updated: 2026-07-28 · Authors: Bugrahan Saka · Categories: Scraping Use Cases, Scraping Tools Your pipelines live in Databricks. A lot of the data you want doesn't. It's sitting on websites, product listings, prices, search results, public directories, in a form no upstream table will ever hand you. So the obvious move is to `import requests` in a notebook and start pulling pages down directly into the lakehouse. That works for about five minutes. Then the blocks start. Your cluster speaks to the world through one IP address, and a website notices very quickly when a single IP asks for its 500th page in a row. What follows is the usual wall: throttling, CAPTCHAs, and JavaScript-rendered pages that return an HTML skeleton with none of the data you came for. This guide takes the requests off your cluster's shoulders. You'll route them through [Scrape.do](https://scrape.do) instead, and land the results directly in a **Delta table**, ready for the SQL, ML, and AI workloads already sitting downstream. Everything here runs on **Databricks Free Edition** (serverless), so you can follow along without a paid workspace, and there's a genuinely interesting architecture decision waiting in the middle of it that most "scraping in Spark" tutorials get wrong. ## The shape of the notebook Four steps, each one cell: 1. Take your Scrape.do token through a notebook widget, never hardcoded. 2. Wrap the Scrape.do API in a reusable `scrape_do()` helper with automatic retries. 3. Fetch a list of URLs in parallel with `ThreadPoolExecutor`. 4. Write the results, URL, status, raw HTML, error, timestamp, into a Delta table. By the end you have a table you can query, parse, join, or feed to a model, refreshed by re-running the notebook or by attaching a schedule to it. ## Why the requests go through Scrape.do Scrape.do is a web scraping API: send it a target URL, get the page content back. The parts that break a cluster-side scraper, it absorbs on its side. - **Rotating proxies** mean each request can leave from a different IP, including residential and mobile ones for stubborn targets with `super=true`, instead of your cluster's single fixed address. - **Anti-bot bypass and CAPTCHA handling** happen server-side, invisible to your notebook code. - **JavaScript rendering** kicks in with `render=true`, a real headless browser draws the page before the HTML comes back. Two economics worth pinning down before you run anything: you're **only charged for successful (2xx) responses**, so the dead links and transient blocks you hit while testing cost nothing, and the **free tier is 1,000 requests a month**, more than enough for this whole tutorial. [Sign up here](https://scrape.do). ## Step 1 — Spin up the workspace 1. Head to [databricks.com/learn/free-edition](https://www.databricks.com/learn/free-edition) and sign up (Google/Microsoft login or email OTP). 2. When the workspace opens, click **+ New → Notebook** in the top-left. 3. In the notebook's top-right, confirm the compute selector reads **Serverless**. Free Edition is serverless-only, and that's all this needs. ## Step 2 — Token widget and a connectivity check Paste this into the first cell and run it: ```python import requests import urllib.parse # Token widget — safe to share the notebook, every user supplies their own token dbutils.widgets.text("scrapedo_token", "", "Scrape.do API Token") TOKEN = dbutils.widgets.get("scrapedo_token") # Quick connectivity test target = "https://httpbin.org/ip" url = f"https://api.scrape.do/?token={TOKEN}&url={urllib.parse.quote_plus(target)}" r = requests.get(url, timeout=60) print("Status:", r.status_code) print(r.text[:500]) ``` After the first run, a text box labeled **Scrape.do API Token** appears at the top of the notebook. Paste your token **into that box**, not into the code, then run the cell again. Widget values are read on the next execution, which is a quirk worth remembering before you wonder why `TOKEN` looked empty the first time. A `Status: 200` with a JSON body carrying an IP address proves the two things this entire integration rests on: your serverless compute has outbound internet access, and your token works. Grab that token from the [Scrape.do dashboard](https://dashboard.scrape.do), it's on the main screen right after login. ## Step 3 — The `scrape_do()` helper Second cell: ```python import time def scrape_do(target_url, render=False, super_proxy=False, max_retries=3): """Scrape a URL via Scrape.do. Returns dict with url, status, html, error.""" params = {"token": TOKEN, "url": target_url} if render: params["render"] = "true" if super_proxy: params["super"] = "true" api_url = "https://api.scrape.do/?" + urllib.parse.urlencode(params) for attempt in range(1, max_retries + 1): try: r = requests.get(api_url, timeout=120) if r.status_code == 200: return {"url": target_url, "status": 200, "html": r.text, "error": None} if r.status_code == 502 and attempt < max_retries: time.sleep(2 * attempt) # transient ROTATION_FAILED — not charged, retry continue return {"url": target_url, "status": r.status_code, "html": None, "error": r.text[:300]} except requests.exceptions.RequestException as e: if attempt < max_retries: time.sleep(2 * attempt) continue return {"url": target_url, "status": None, "html": None, "error": str(e)[:300]} # Test result = scrape_do("https://books.toscrape.com/") print("Status:", result["status"]) print("HTML length:", len(result["html"]) if result["html"] else 0) ``` Expected output: ``` Status: 200 HTML length: 51294 ``` Three details in that helper earn their place. `urllib.parse.urlencode` handles the encoding for you, skip it and Scrape.do rejects the request with *"Your target 'URL' is not valid."* A transient `502 ROTATION_FAILED` happens when a proxy hop fails; it's **not charged**, so the helper just waits and retries rather than giving up, and if a target keeps failing, `super_proxy=True` routes it through residential and mobile IPs. And `render` and `super_proxy` default off because both cost extra credits, you turn them on per call, only for the targets that need them. ## Step 4 — Parallel fetch straight into Delta Third cell: ```python from concurrent.futures import ThreadPoolExecutor from pyspark.sql import Row from pyspark.sql.types import StructType, StructField, StringType, IntegerType from datetime import datetime, timezone # URLs to scrape — replace with your own targets urls = [ "https://books.toscrape.com/catalogue/page-1.html", "https://books.toscrape.com/catalogue/page-2.html", "https://books.toscrape.com/catalogue/page-3.html", "https://books.toscrape.com/catalogue/page-4.html", "https://books.toscrape.com/catalogue/page-5.html", ] # Parallel scrape (5 concurrent requests) with ThreadPoolExecutor(max_workers=5) as pool: results = list(pool.map(scrape_do, urls)) scraped_at = datetime.now(timezone.utc).isoformat() rows = [ Row( url=r["url"], status=r["status"], html=r["html"], error=r["error"], scraped_at=scraped_at, ) for r in results ] # Define the schema explicitly — don't let Spark infer it schema = StructType([ StructField("url", StringType(), False), StructField("status", IntegerType(), True), StructField("html", StringType(), True), StructField("error", StringType(), True), StructField("scraped_at", StringType(), False), ]) df = spark.createDataFrame(rows, schema=schema) # Write to a Delta table df.write.mode("append").saveAsTable("workspace.default.scraped_pages") print(f"Scraped {len(rows)} URLs — {sum(1 for r in results if r['status'] == 200)} success") display(spark.table("workspace.default.scraped_pages").select("url", "status", "scraped_at")) ``` Expected output: ``` Scraped 5 URLs — 5 success ``` …then the table rows: one per page, with its URL, HTTP status, and timestamp. The raw HTML rides in the `html` column of the same table, so from here it's an ordinary Delta table, parse it with SQL functions, hand it to an LLM, or join it against the rest of your lakehouse. Each run **appends**, so scheduled re-scrapes accumulate a history automatically; switch to `mode("overwrite")` if you only ever want the latest snapshot. ## The architecture decision worth understanding Here's where most "scraping in Spark" tutorials steer you wrong. The instinctive Spark move is to wrap `scrape_do` in a **UDF** and fan the scraping out across executors, that's the whole point of Spark, right, distribute the work. On serverless compute, which is all Free Edition offers, that approach doesn't merely underperform. It cannot run: **serverless UDFs have no outbound internet access.** The executors are sandboxed off the network. The driver, on the other hand, does have internet. So the driver-side `ThreadPoolExecutor` you saw above isn't a workaround for a missing feature, it's the correct architecture for this tier. And it holds up well beyond Free Edition, because scraping is **I/O-bound**. The threads spend nearly all their time waiting on the network, not burning CPU, so 5 to 20 workers on a single driver saturate most scraping jobs. On classic compute clusters a UDF-based fan-out *can* work, but for typical volumes the ThreadPool version is simpler and just as fast, since the bottleneck was never CPU. It was always the network. ## The gotchas we actually hit building this These aren't hypothetical, every one of them cost us a run while writing this notebook. **`NameError: name 'TOKEN' is not defined`.** Serverless sessions reset after idle time and wipe every variable with them. Re-run from the top (or hit **Run all**). Cell order is load-bearing: widget → helper → pipeline. **`[CANNOT_DETERMINE_TYPE]` when building the DataFrame.** If every scrape succeeds, `error` is `None` in every row and Spark can't infer that column's type. That's exactly why the code declares the schema explicitly with `StructType`. Never let Spark infer a schema for columns that can come back all-null. **`[SCHEMA_NOT_FOUND] The schema main.default cannot be found`.** Databricks docs love `main` as the catalog name, but your workspace's default catalog may differ, on our Free Edition workspace it was `workspace`. Check yours: ```python display(spark.sql("SHOW CATALOGS")) print(spark.sql("SELECT current_catalog(), current_schema()").collect()) ``` Then use `.default.scraped_pages` as the table name. **Transient `502 ROTATION_FAILED`.** A proxy hop occasionally fails on Scrape.do's side. It isn't charged and a retry almost always clears it, the helper handles this. Persistent 502s on one domain mean the target is genuinely hard; reach for `super_proxy=True`. **Widget values read on the *next* run.** Run the first cell, paste the token into the widget box, run it again. If `TOKEN` is empty, this is why. **Store raw HTML deliberately.** A Delta string column happily holds full-page HTML (50 KB+ per row), unlike spreadsheet cells that truncate. But if you only need a few fields, parse them before writing and keep the table lean, and keep input columns (URLs) strictly separate from output columns (HTML) so a re-run never feeds scraped output back in as input. ## Run order and scheduling 1. **Cell 1** — widget + connectivity test (paste the token, run twice on first use). 2. **Cell 2** — the `scrape_do()` helper. 3. **Cell 3** — parallel scrape and Delta write. Once it's set up, **Run all** executes the whole pipeline in a click, and the **Schedule** button in the notebook toolbar turns it into a recurring scraping job with no extra code. That's the payoff of landing scraped data in the lakehouse directly: the scheduling, retries, and downstream analytics you already have all apply to web data the moment it arrives. ## Start scraping Everything above runs on free tiers end to end, Databricks Free Edition for compute, and **1,000 free requests a month** from Scrape.do for the scraping, with proxy rotation, anti-bot bypass, CAPTCHA solving, and JavaScript rendering included, billed only for the responses that actually succeed. 👉 [Get your free Scrape.do API token](https://scrape.do) and turn any website into a Delta table. **Downloads:** - [`scrapedo-databricks-notebook.ipynb`](/uploads/blog/scrapedo-databricks-notebook.ipynb) — the complete notebook from this post