For agents and RAG, converting webpages to Markdown is the easy part. Getting the page is harder. A plain fetch can return a bot-check page or an empty shell that needs JavaScript to fill in, and the request still looks successful. The model then gets no page content.

This guide shows how to get the real page as Markdown with a short Python script and the Decodo Web Data API.

TL;DR

  • A POST to the Decodo Web Data API's /scrape endpoint with "markdown": true returns a page as Markdown. You can start on the free plan.
  • The script starts with the cheapest request, checks each result for page content, and adds JavaScript rendering or the premium proxy pool only when needed.
  • On 22 public test pages, the script converted 20, headless Chromium 18, and plain requests 15.

What you'll need

  • Python 3.10+ and the Requests library
  • A Decodo account on the Web Data API Free plan

The /scrape endpoint in 60 seconds

The /scrape endpoint handles proxy rotation, JavaScript rendering, and anti-bot protection.

Send the URL with "markdown": true. The page is returned in results, with the site's own status_code, so you can detect error pages.

Step 1: Get your API key

Activate the Web Data API free plan and copy your API key from the dashboard.

How to get API key for Decodo Web Data API?

Install Requests and export the key:

pip install requests export DECODO_API_KEY="your-api-key" 

In PowerShell, use $env:DECODO_API_KEY="your-api-key" instead.

Step 2: Convert a page to Markdown

Save this as url_to_markdown.py. The script tries each request type in LADDER, cheapest first, and stops at the first usable result:

import os import re import sys from pathlib import Path from urllib.parse import urljoin, urlsplit import requests from requests.adapters import HTTPAdapter from urllib3.util import Retry API_URL = "https://data.decodo.com/v1/scrape" KEY = os.environ.get("DECODO_API_KEY") or sys.exit("Set DECODO_API_KEY") HEADERS = {"Authorization": f"Bearer {KEY}"} # A page with fewer words probably needs rendering or the premium pool MIN_WORDS = 100 # Cheapest first, by the per-request prices on the billing page LADDER = [ ("standard", False), ("standard", True), ("premium", False), ("premium", True), ] KNOWN_STEP = {} # Domain -> index of the LADDER entry that worked # Chinese and Japanese (about 2 characters a word) and Thai (about 6) # don't put spaces between words NO_SPACES = {r"[\u3040-\u30ff\u4e00-\u9fff]": 2, r"[\u0e00-\u0e7f]": 6} SESSION = requests.Session() # urllib3 skips POST retries by default # (see Retry.DEFAULT_ALLOWED_METHODS). # A /scrape call is safe to repeat. SESSION.mount( "https://", HTTPAdapter( max_retries=Retry( total=3, backoff_factor=5, status_forcelist=[429, 500, 524], allowed_methods=None, ) ), ) def word_count(text): return len(text.split()) + sum( len(re.findall(p, text)) // n for p, n in NO_SPACES.items() ) class ScrapeError(Exception): def __init__(self, code, message): super().__init__(f"{code}: {message}") self.code = code def scrape(url, pool, render_js): body = {"url": url, "markdown": True, "proxy_pool": pool} if render_js: body["headless"] = "html" resp = SESSION.post(API_URL, json=body, headers=HEADERS, timeout=180) data = resp.json() if resp.ok and data.get("status") != "failed": return next((r for r in data["results"] if r["type"] == "markdown"), None) raise ScrapeError(data.get("status_code", resp.status_code), data.get("message")) def url_to_markdown(url, render_js=False, pool="standard"): domain = urlsplit(url).netloc step = max(LADDER.index((pool, render_js)), KNOWN_STEP.get(domain, 0)) while step < len(LADDER): pool, render_js = LADDER[step] step += 1 try: result = scrape(url, pool, render_js) except ScrapeError as err: # Some sites need the premium pool for Markdown. After a  # 400 from standard, try premium. A 400 from premium raises.  if err.code == 400 and pool == "standard": step = LADDER.index(("premium", render_js)) # Codes for "try the next attempt", explained at  # help.decodo.com/docs/web-data-api-status-codes  elif err.code not in (612, 613, 999): raise continue if result is None: # Try the next attempt  continue if result["status_code"] in (404, 410): raise ScrapeError(result["status_code"], "page not found") ok = result["status_code"] < 400 and "help" not in result if ok and word_count(result["content"]) >= MIN_WORDS: KNOWN_STEP[domain] = step - 1 break else: raise ScrapeError("all attempts", "no page content found") content = result["content"] # Remove the navigation above the title  title = re.search(r"^# ", content, re.M) # A "#" after a code fence may be a code comment, not a title  fence = re.search(r"^`{3}", content, re.M) if title and fence and fence.start() < title.start(): title = None if title and word_count(content[title.start() :]) >= MIN_WORDS: content = content[title.start() :] # Absolute links keep citations working outside this site  content = re.sub(r"\]\(([^)\s]+)", lambda m: "](" + urljoin(url, m[1]), content) slug = re.sub(r"\W+", "-", url.split("://")[-1]).strip("-")[:150] path = Path("pages") / (slug + ".md") path.parent.mkdir(exist_ok=True) path.write_text(content, encoding="utf-8") words = word_count(content) print(f"saved {path} ({words:,} words, {pool}, js={render_js})") return content if __name__ == "__main__": for url in sys.argv[1:]: try: url_to_markdown(url) except (ScrapeError, requests.RequestException) as err: print(f"FAILED {url}: {err}", file=sys.stderr) 

Run it on a static docs page:

python url_to_markdown.py https://docs.python.org/3/library/json.html 

The script converts the page on the cheapest attempt:

saved pages/docs-python-org-3-library-json-html.md (4,359 words, standard, js=False) 

The saved file starts at the page title, and its links are absolute:

# `json` — JSON encoder and decoder[¶](https://docs.python.org/3/library/json.html#module-json "Link to this heading") **Source code:** [Lib/json/\_\_init\_\_.py](https://github.com/python/cpython/tree/3.15/Lib/json/__init__.py) ... 

Steps 3 to 5 explain parts of this script. You don't need to add code.

Step 3: Handle JavaScript-rendered pages

Swagger UI renders API reference pages in the browser. Here's the Petstore demo:

Petstore demo with Web Data API.

The server's HTML body has only an empty div and scripts:

The server's HTML body has only an empty div and scripts.

The /scrape response marks a page like this with a help field in its result. When a page has that field, or fewer than MIN_WORDS words, the script moves to the next attempt, which renders the page with "headless": "html":

python url_to_markdown.py https://petstore.swagger.io/ 

Rendering returns every endpoint:

saved pages/petstore-swagger-io.md (172 words, standard, js=True) 

Step 4: Pick the proxy pool

Standard handles simple pages, and premium is built for complex anti-bot protection. Each LADDER entry sets the proxy_pool field to standard or premium. Standard costs less per request, so the script starts there. See the billing page for current prices. For a site that needs premium, call url_to_markdown(url, pool="premium").

The free plan includes both pools and JavaScript rendering, so the whole script runs on it.

Step 5: Run it on long URL lists

These 4 parts of the script keep the output clean and the cost low:

  • Status checks. The script checks the site's own status_code and doesn't save error pages. For a site you scrape often, also check for a phrase that every real page has. Then you can safely lower MIN_WORDS for its short pages.
  • Per-domain cache. KNOWN_STEP remembers the attempt that worked on each domain, so later pages there usually need 1 request. In a long-running process, clear KNOWN_STEP regularly, because sites change.
  • Navigation removal. The script removes the navigation above the page's # title, so your chunks don't contain it.
  • Absolute links. Relative links become absolute, so citations work outside the site.

Pass several URLs:

python url_to_markdown.py \ https://www.law.cornell.edu/wex/contract \ https://docs.python.org/3/library/no-such-page.html 

The missing page is logged, not saved:

saved pages/www-law-cornell-edu-wex-contract.md (809 words, standard, js=False) FAILED https://docs.python.org/3/library/no-such-page.html: 404: page not found 

How it compares with Requests and Playwright

Each method fetched the same 22 public pages whose robots.txt allows AI crawlers such as GPTBot and ClaudeBot. Only this script used proxies. A page was converted if its Markdown contained a phrase from the main content.

Method Pages converted
This script with Decodo's Web Data API 20
Headless Chromium with Playwright 18
Plain Requests with markdownify 15

The script also converted every page that the other methods did. Most of its 20 conversions needed only the cheapest attempt.

Edge cases and common problems

  • Prompt injection. Attackers can use alt text, display:none blocks, or white-on-white text to hide instructions for models. Treat page Markdown as data, and put it between clear delimiters. Don't trigger a tool call only because page text asks for it.
  • Choosing pages to convert. The Web Data API is built for publicly accessible pages. Check each site's terms of service.

Final thoughts

The Decodo Web Data API returns Markdown with 1 parameter, and the script checks each result before saving the page.

Next steps:

Start with the Web Data API free plan, and compare the script's Markdown with your current pipeline's output.