Scrapejig

Guide, run and checked with Scrapejig 1.0.0 on 10 September 2026

How to scrape an infinite scroll page with Playwright (Python)

By the end you will have a complete Python script that scrolls a page, waits for new rows to arrive, notices when they stop arriving, and writes what it collected to a CSV — with no page numbers and no "load more" button to click, because the page in this tutorial has neither. It runs against a public practice site built for exactly this, and the terminal output and the CSV it produced are pasted in below, unedited.

Why infinite scroll is a different problem.

A paginated listing tells you when it is finished: the next link disappears, or the URL stops changing. Infinite scroll never tells you anything. The page you start on already has the first batch of items in it, and more arrive only because something noticed you were near the bottom and fetched another batch — there is no link to follow and no number of pages to count towards. The script has to manufacture the two things pagination gives you for free: a way to ask for more, and a way to know when to stop asking.

Asking for more is the easy half. Most infinite-scroll pages are listening for scroll position, so moving the viewport is enough to trigger the next fetch — you do not need to find and click anything. Knowing when to stop is the half people get wrong, usually by scrolling a fixed number of times and hoping that is enough, or by scrolling forever and never stopping at all. Neither survives contact with a real site: too few scrolls and you miss rows that were there all along; too many and a feed that genuinely never ends — a lot of social timelines are built that way on purpose — keeps your script running until you notice and kill it.

The fix is to watch the page rather than guess at it: scroll, wait, count what is on the page, and stop once counting stops finding anything new.

Building the script.

The target is quotes.toscrape.com/scroll, a page the same site publishes specifically for practising this — the ordinary quotes.toscrape.com is paginated, and this variant of it loads more quotes as you scroll instead. Ten quotes are visible on load; the full set is a hundred.

1. Open the page and read what is already there.

Nothing scroll-specific yet. Launch a browser, load the page, and wait for at least one item to exist before reading anything — the page has to render before div.quote means anything.

infinite_scroll.py step 1
page.goto(START_URL)
page.wait_for_selector(ITEM_SELECTOR)

2. Scroll, wait, and count.

page.mouse.wheel(0, 20000) scrolls by dispatching a real wheel event, with a delta large enough to reach the bottom of most feeds in one go. page.evaluate("window.scrollTo(0, document.body.scrollHeight)") is the alternative you will see elsewhere, and on this page it collects the same hundred quotes: moving the scroll position programmatically still fires the scroll event, and still trips an IntersectionObserver sentinel, so the usual triggers see it either way. Two narrower differences are why wheel is the default here. A handler bound to wheel rather than to scroll only ever sees the wheel version, and scrollTo aimed at the window does nothing when the feed scrolls inside its own overflowing div rather than the document. Neither of those makes scrollTo a lesser scroll, so keep it in your pocket for the pages where wheel does nothing.

After each scroll there has to be a pause before you count, because the new rows arrive over the network and are not there the instant the scroll finishes. A fixed time.sleep() is what the script below uses, and it is a compromise, not the ideal: the honest way to wait is for the actual request to settle, with something like page.expect_response(lambda r: "quotes" in r.url) as a context manager wrapped around the scroll, aimed at the specific XHR the page fires and armed before the scroll rather than after it, or page.wait_for_load_state("networkidle") if the feed only ever fetches on scroll and nothing else is polling in the background. Both are more precise than a sleep when they are armed correctly, and both are more fragile: the first needs you to know the request shape, which changes if the site does; the second returns at once if the page happens to be idle in the instant after the scroll, before the fetch has started, and on a page carrying an ad script or an analytics beacon that pings on its own schedule it never returns at all. A sleep long enough for the slowest batch you have seen is blunt, but it does not silently hang on a page with unrelated background traffic — which is why it is the default here and DELAY_SECONDS is the knob to raise if a site is slower than one second.

What you count matters as much as how you wait. Count elements matching the item selector, not page height or scrollHeight: a footer, an ad slot, or an image that resizes once it loads will all change the page's height without a single new row existing, and a height-based check reads that as progress.

infinite_scroll.py step 2
page.mouse.wheel(0, 20000)
time.sleep(DELAY_SECONDS)
count = len(page.query_selector_all(ITEM_SELECTOR))

3. Stop on quiet rounds, with a hard cap underneath.

One scroll that adds nothing is not proof the feed is finished — the previous batch might simply still be in flight. Stopping after the first flat count cuts pages short for no reason. The fix is patience with a limit: keep a running total of the highest count seen, and only stop once several scrolls in a row have failed to raise it. Three quiet rounds in a row is enough to be confident without waiting through an unreasonable number of empty scrolls first.

Underneath that sits a second, unconditional stop: a hard cap on the number of scrolls, regardless of whether the count is still growing. Without it, a feed that genuinely has no end — and some are built that way deliberately — never satisfies the quiet-rounds check and the script runs until something else kills it. The cap is not a tuning knob you are meant to hit in normal use; it is the backstop for the page that turns out not to behave like you expected.

infinite_scroll.py step 3
while quiet_rounds < QUIET_ROUNDS_LIMIT and scrolls < MAX_SCROLLS:
    ...
    if count > seen:
        seen = count
        quiet_rounds = 0
    else:
        quiet_rounds += 1

Notice this compares against seen — the highest count recorded so far — rather than against last round's count. That distinction only bites on a feed that recycles old rows out of the DOM as new ones arrive to keep the page light, where the live count can genuinely fall between two scrolls. Against the previous round that fall-then-rise reads as new content and the quiet counter never accumulates; against the running maximum it correctly reads as nothing new. quotes.toscrape.com/scroll does not recycle anything — it keeps every row it has loaded — but the check costs nothing to include and saves you rediscovering the bug on a page that does.

4. Read the rows once, and write the CSV.

The loop's job is only to make sure everything has loaded; it does not need to collect anything itself. Once it exits, query the item selector one last time and read each item's fields, then hand the list to csv.DictWriter.

infinite_scroll.py step 4
with open("scroll_output.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=["quote", "author"])
    writer.writeheader()
    writer.writerows(rows)

A note on being polite while you do all this. Scrolling fires a request every second or so for as long as the loop runs, which is more traffic against one site in a short window than a person browsing would ever generate — check the site's robots.txt before pointing this at anything that is not a practice page, and keep DELAY_SECONDS at a second or more rather than shortening it to finish sooner. A script that finishes in half the time but gets an IP blocked has not actually saved anyone anything.

The full script.

Everything above, assembled. Nothing here needs a class, a config file or a logging setup — it is one loop and one write.

infinite_scroll.py complete
"""Scrape https://quotes.toscrape.com/scroll — a public practice page built for
exactly this exercise. Every quote and author is loaded the same way: more rows
arrive as you scroll down, with no page numbers and no "load more" button.

Run:
    pip3 install playwright
    python3 -m playwright install chromium
    python3 infinite_scroll.py
"""

import csv
import time

from playwright.sync_api import sync_playwright

START_URL = "https://quotes.toscrape.com/scroll"
ITEM_SELECTOR = "div.quote"
DELAY_SECONDS = 1.0    # pause after each scroll, waiting for the next batch
QUIET_ROUNDS_LIMIT = 3  # stop after this many scrolls in a row add nothing
MAX_SCROLLS = 200       # hard stop, in case the feed never runs out


def scrape(page):
    page.goto(START_URL)
    page.wait_for_selector(ITEM_SELECTOR)

    seen = 0
    quiet_rounds = 0
    scrolls = 0

    while quiet_rounds < QUIET_ROUNDS_LIMIT and scrolls < MAX_SCROLLS:
        page.mouse.wheel(0, 20000)
        scrolls += 1
        time.sleep(DELAY_SECONDS)

        count = len(page.query_selector_all(ITEM_SELECTOR))
        if count > seen:
            seen = count
            quiet_rounds = 0
        else:
            quiet_rounds += 1

    rows = []
    for item in page.query_selector_all(ITEM_SELECTOR):
        rows.append({
            "quote": item.query_selector("span.text").inner_text(),
            "author": item.query_selector("small.author").inner_text(),
        })
    return rows


def main():
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()
        rows = scrape(page)
        browser.close()

    with open("scroll_output.csv", "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=["quote", "author"])
        writer.writeheader()
        writer.writerows(rows)

    print(f"Scraped {len(rows)} rows -> scroll_output.csv")


if __name__ == "__main__":
    main()

Running it.

This is the actual run, on the actual page, not a paraphrase of what it should do. python3 infinite_scroll.py against quotes.toscrape.com/scroll:

terminal real output
$ python3 infinite_scroll.py
Scraped 100 rows -> scroll_output.csv

And the first three lines of the CSV it wrote:

scroll_output.csv first 3 lines
quote,author
“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”,Albert Einstein
"“It is our choices, Harry, that show what we truly are, far more than our abilities.”",J.K. Rowling

A hundred rows is the whole set — the page has no more than that to give, and the run above found all of it in one pass, no quiet-round tuning required.

Or: don't write it.

Scrapejig generates this exact kind of file from a few clicks: click the quote and the author on the page, choose Infinite scroll in the panel's Flow dropdown, press Export Python. £29 once, no subscription. How each flow mode is generated covers infinite scroll alongside pagination and load-more; the full annotated export walks through everything a generated file contains; pricing has the rest.

Below is the file Scrapejig exports for this same page, unedited — same start URL, same two fields, same flow mode, nothing added or removed by hand.

scrape_quotes_toscrape_com.py generated, unedited
"""Scrape quotes.toscrape.com — generated by Scrapejig on 2026-09-10.

Setup (one time):
    pip3 install playwright
    python3 -m playwright install chromium

Run:
    python3 scrape_quotes_toscrape_com.py

If pip3 says "externally-managed-environment" (Homebrew, Debian, Ubuntu 23.04+),
create a virtual environment and run all three lines inside it:
    python3 -m venv .venv && source .venv/bin/activate
Every new terminal needs that activate line again before the script will run.

On Windows the launcher is py: py -m pip install playwright, then
py -m playwright install chromium, then py scrape_quotes_toscrape_com.py.
A venv activates there with .venv/Scripts/activate in place of the source line.

Output: scrape_output.csv and scrape_output.json in the working directory.
This file is yours — edit it freely. The constants below are the usual knobs.
"""

import csv
import json
import sys
import time

from playwright.sync_api import sync_playwright

START_URL = "https://quotes.toscrape.com/scroll"
HEADLESS = True
OUTPUT_BASE = "scrape_output"
DELAY_SECONDS = 1.0  # pause between page loads; raise to be gentler
ITEM_SELECTOR = "div.quote"
FIELDS = ["quote", "author"]
MAX_SCROLLS = 200  # hard stop; a feed that never ends would scroll forever


def extract_item(item):
    """Pull one row's fields from a single item element."""
    return {
        "quote": text(item, "span.text"),
        "author": text(item, "small.author"),
    }


def rendered_text(node):
    """The text a node renders. inner_text() is HTML-only and raises on SVG and other
    non-HTML nodes, where text_content() asks the same question and answers it.
    """
    try:
        return node.inner_text().strip()
    except Exception:
        pass
    try:
        return (node.text_content() or "").strip()
    except Exception:
        return ""


def text(el, selector):
    node = el.query_selector(selector)
    return rendered_text(node) if node else ""


def rows_on(page):
    """Every real item on the page in front of us.

    A row whose every column came back empty means the item selector matched something that is
    not an item — a page section sitting beside the real cards — so it is dropped rather than
    padding the output with blank lines.
    """
    rows = []
    for item in page.query_selector_all(ITEM_SELECTOR):
        row = extract_item(item)
        if any(row.values()):
            rows.append(row)
    return rows


# The tail every start-page failure shares. A wall served to a fresh profile explains a
# navigation that hangs, a 403, and a document with no items in it equally well.
BLOCKED_ADVICE = (
    "A site will often serve a bot check or a cookie wall to a fresh headless profile "
    "instead of the page. Set HEADLESS = False to see what came back, and if it is a "
    "banner or a login, record a session with the storage_state note in main()."
)


def open_start_page(page):
    """Load the start page and wait for the items, explaining what went wrong before it bites."""
    try:
        # "domcontentloaded", not Playwright's default "load". The default waits for every
        # subresource the page pulls in, so one hanging tracker or ad pixel times the navigation
        # out thirty seconds after the markup — all of it, items included — already arrived.
        # Readiness here is "are the items there", and the wait below asks exactly that.
        response = page.goto(START_URL, wait_until="domcontentloaded")
    except Exception:
        print(f"Could not load {START_URL}: the document never arrived.", file=sys.stderr)
        print(BLOCKED_ADVICE, file=sys.stderr)
        raise
    # goto() raises on a transport failure but not on an HTTP one: 404, 403 and 500 are all
    # successful navigations to a page that says no. Unchecked, they fall through to the wait
    # below, cost its full timeout, and are then reported as something they are not.
    if response is not None and not response.ok:
        print(
            f"{START_URL} answered HTTP {response.status}, so the items never loaded.",
            file=sys.stderr,
        )
        print(
            "A 404 or 410 means START_URL is wrong or the listing has moved — open it in a "
            "normal browser and check. A 401, 403 or 429 is usually a wall rather than a "
            "verdict on the URL:",
            file=sys.stderr,
        )
        print(BLOCKED_ADVICE, file=sys.stderr)
        raise RuntimeError(f"HTTP {response.status} at {START_URL}")
    try:
        page.wait_for_selector(ITEM_SELECTOR)
    except Exception:
        print(f"Nothing matched ITEM_SELECTOR on {START_URL}.", file=sys.stderr)
        print(BLOCKED_ADVICE, file=sys.stderr)
        raise


def scrape(page):
    """Scroll until the item count stops growing (3 quiet rounds)."""
    open_start_page(page)
    quiet_rounds = 0
    seen = 0
    scrolls = 0
    while quiet_rounds < 3 and scrolls < MAX_SCROLLS:
        try:
            page.mouse.wheel(0, 20000)
        except Exception as error:
            # Whatever stopped the scroll, what has already loaded is on the page and is about
            # to be read. Ending here costs the rest of the feed, not the whole run.
            print(f"Stopped scrolling: {error} — reading what has loaded.", file=sys.stderr)
            break
        scrolls += 1
        time.sleep(DELAY_SECONDS)
        count = len(page.query_selector_all(ITEM_SELECTOR))
        quiet_rounds = quiet_rounds + 1 if count <= seen else 0
        # The highest count seen, not the latest one. A virtualized feed drops the items you
        # scrolled past, so the live count falls and then rises again; against the latest
        # count that rise looks like new content, resets quiet_rounds, and the loop never ends.
        seen = max(seen, count)
    return rows_on(page)


def main():
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=HEADLESS)
        page = browser.new_page()
        # Cookie banner or login wall? This browser starts with a clean profile that has
        # agreed to nothing, so plenty of sites — most UK and EU ones — cover the page with a
        # consent overlay that swallows every click, and a login-gated site never shows you
        # the content at all. Fix both the same way: record a session once with
        #     python3 -m playwright codegen --save-storage=auth.json "https://quotes.toscrape.com/scroll"
        # dismiss the banner in the window that opens (and sign in, if you need to), close it,
        # then replace the new_page() line above with:
        #     page = browser.new_context(storage_state="auth.json").new_page()
        rows = scrape(page)
        browser.close()
    write_output(rows)
    print(f"Scraped {len(rows)} rows -> {OUTPUT_BASE}.csv and {OUTPUT_BASE}.json")


def write_output(rows):
    with open(f"{OUTPUT_BASE}.csv", "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=FIELDS)
        writer.writeheader()
        writer.writerows(rows)
    with open(f"{OUTPUT_BASE}.json", "w", encoding="utf-8") as f:
        json.dump(rows, f, indent=2, ensure_ascii=False)


if __name__ == "__main__":
    main()

Run the same way, it printed:

terminal real output
$ python3 scrape_quotes_toscrape_com.py
Scraped 100 rows -> scrape_output.csv and scrape_output.json

Same hundred rows, but the generated file does more than the tutorial script does. Its own quiet-round logic lives inside scrape() with the same highest-count check built in, so you are not the one who has to remember it. It writes JSON alongside the CSV without being asked. Every tunable — DELAY_SECONDS, MAX_SCROLLS, ITEM_SELECTOR, HEADLESS — sits as a named constant at the top rather than buried in the loop. And it checks the HTTP response and reports a likely bot wall or consent banner on stderr before it fails, rather than leaving you to work out why wait_for_selector timed out.