JavaScript-rendered pages

Scrapy · Advanced · 12 min

Many modern sites ship empty HTML shells, and choosing the right technique decides between a fast crawler and a slow, fragile one.

Scrapy downloads HTML; it doesn't run JavaScript. If view(response) in the shell shows a page without the data, the content is rendered client-side. In order of preference:

  • Find the data source: open the browser's network tab, filter by XHR/fetch, and look for JSON responses containing the data. Reproduce the request in Scrapy (same URL, query parameters and important headers) and parse with response.json(). This is usually the fastest, most robust option.
  • Embedded state: frameworks often put initial data in the HTML, in <script id="__NEXT_DATA__" type="application/json">, window.__INITIAL_STATE__ = {...} or JSON-LD. Extract the script text with a selector and json.loads it (a regex or the chompjs library helps with JavaScript object literals).
  • Render the page with a headless browser via scrapy-playwright: install it, configure its download handler and the asyncio reactor, and mark requests with meta={"playwright": True}. You can wait for selectors, click and scroll via page methods. Rendering is orders of magnitude slower and heavier, so render only the pages that need it.

Hosted rendering and anti-bot services exist too (for example Zyte API), integrated as download handlers or middlewares.

Whatever you choose, keep extraction code separate from fetching, so switching from rendered HTML to a discovered API later doesn't require rewriting your parsers.

Example

import json
import scrapy

class ListingsSpider(scrapy.Spider):
    name = "listings"
    start_urls = ["https://example.com/listings"]

    def parse(self, response):
        # Option 1: data embedded by a Next.js app
        raw = response.css("script#__NEXT_DATA__::text").get()
        if raw:
            for item in json.loads(raw)["props"]["pageProps"]["listings"]:
                yield {"id": item["id"], "title": item["title"]}
            return
        # Option 2: render with a browser (scrapy-playwright)
        yield scrapy.Request(response.url, callback=self.parse_rendered,
                             meta={"playwright": True}, dont_filter=True)

    def parse_rendered(self, response):
        for card in response.css("div.listing"):
            yield {"title": card.css("h2::text").get()}

# settings.py for scrapy-playwright:
# DOWNLOAD_HANDLERS = {"https": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler",
#                      "http": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler"}

Remember

  • Check view(response): missing data means client-side rendering
  • Best: call the JSON API the page uses
  • Next: parse embedded JSON state from script tags
  • Last resort: render with scrapy-playwright, only for pages that need it

Watch out for

  • Rendering every page with a browser when one JSON endpoint would return all the data.

Check yourself

  1. Which approach is usually fastest and most stable for a JavaScript-heavy site?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.scrapy.org/en/latest/topics/dynamic-content.html

Builds on

Unlocks

Nothing builds on this yet.