Scrapejig

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

Export any HTML table from a website to CSV

There are three ways to do this, and they take very different amounts of effort. Copy and paste into a spreadsheet costs nothing and is enough for a single table you only need once. A browser extension that reads the table for you is the next step up, worth it once you are doing this more than a couple of times. Writing a script is the only one of the three that survives pagination, a table that loads in after the page renders, or a job you need to run again next month — and below is a complete one, tested against a real site, that you can copy, run and adapt yourself.

Method 1: copy and paste into a spreadsheet

Click and drag to select the table on the page — or click inside it and press Ctrl+A / Cmd+A if the site lets a single table take the whole selection — then copy, open Google Sheets or Excel, click the top-left cell, and paste. Most browsers copy an HTML table with its structure intact, and both Sheets and Excel understand that structure well enough to drop it into rows and columns without you doing anything else.

This works when the table is genuinely a <table> element, sits entirely on one page, and is small enough to select by hand — a results table, a pricing grid, a squad list. It is the right tool for a one-off you will use once and never touch again.

It breaks in four fairly common situations, and it is worth knowing which one you have before you spend ten minutes fighting it:

  • Paginated tables. You get whatever page is currently rendered and nothing else. A 24-page table pasted this way gives you the 25 rows on page one and leaves the other 23 pages behind, so getting the rest means clicking Next, selecting, copying and pasting 23 more times, and keeping track of where each paste landed.
  • Tables that load after the page. Plenty of sites render an empty shell first and fill the table in with JavaScript a moment later. If you select and copy before that finishes, you get an empty table or a loading spinner's worth of nothing. Waiting a second usually fixes it; some tables load their next batch only on scroll, which a copy-paste cannot trigger for you.
  • Merged cells. A header that spans two columns, or a row that spans two years, throws off the column count the moment it lands in a spreadsheet. What looked like a clean grid on the page arrives with cells shifted sideways by one, and every row after the merge is misaligned until you fix it by hand.
  • Thousands of rows. Selecting a table that long is fiddly to begin with, and pasting it can make the browser or the spreadsheet visibly hesitate. It is not usually a hard limit, but past a few hundred rows the copy-paste route stops being the fast option it was for a table of twenty.

Method 2: a browser extension that reads the table

A step up from copy-paste: an extension reads the table's markup directly and gives you a clean export button, usually straight to CSV, without the merged-cell and loading-state problems above getting in the way of the selection itself. A fair few of these exist, aimed at exactly this job, and any one of them is a reasonable choice if all you need is a table extract with no repeat visits and no walking through pages.

Scrapejig's own side panel does this too, before you pay for anything: point it at a page, click one cell in each column you want, and the panel shows a live preview table with a Copy CSV button right beside it. Picking and previewing need no key at all; Copy CSV needs a free key, which costs nothing and asks only for an email address. Neither needs the paid licence, and nothing is installed beyond the extension itself. Where it goes further than that kind of tool is the next section: turning "read this table" into "read every page of this table, without you sitting there clicking Next".

Method 3: write a script

The table below lives at scrapethissite.com/pages/forms/, a public sandbox site built specifically for practising scraping, so there is nothing impolite about hammering it while you learn. It lists NHL teams by season — name, year, wins, losses and a few other columns — 25 rows per page, spread across about 24 pages, with a "next" link at the bottom that moves the URL from ?page_num=1 to ?page_num=2 and onward until the pages run out. That link also matters for the bare URL above: click Next from it and it lands on ?page_num=1, page one again, so the scripts below start at ?page_num=1 directly rather than reading page one twice.

Fetching the live markup confirms the bits that matter for a scraper: each row is a <tr class="team">, and the cells you want carry their own classes — td.name, td.year, td.wins, td.losses. The header row is a plain <tr> of <th> elements sitting above the data rows, with no <thead> wrapper. The "next" link is an <a> tagged aria-label="Next", and on the last page — page 24, with 7 rows on it — that link is simply not in the document any more. That last fact matters: it is what tells the script when to stop.

The script below is written with playwright.sync_api, which drives a real (headless) browser rather than parsing raw HTML, so it copes with any JavaScript the page runs and with clicking the next link the same way a person would. Build it up in three pieces.

Step 1: open the page and read the rows in front of you

Start a browser, load the URL, wait for at least one row to exist, then read every tr.team on the page and pull the four cells out of each one:

step 1 read one page
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto("https://www.scrapethissite.com/pages/forms/?page_num=1")
    page.wait_for_selector("tr.team")

    rows = []
    for tr in page.query_selector_all("tr.team"):
        rows.append({
            "team": tr.query_selector("td.name").inner_text().strip(),
            "year": tr.query_selector("td.year").inner_text().strip(),
            "wins": tr.query_selector("td.wins").inner_text().strip(),
            "losses": tr.query_selector("td.losses").inner_text().strip(),
        })
    print(len(rows), "rows")
    browser.close()

That is the whole idea of the scrape: one selector for the repeating row, one selector per column inside it, .inner_text().strip() to get clean text out of each cell. Everything from here is about doing that once per page instead of once.

Step 2: read the header row, so the columns have names

Before writing anything to disk it is worth pulling the header row too — not because the CSV needs it read at runtime, but because it is the cheap way to check your column selectors line up with what the page actually calls them, rather than guessing:

step 2 read the header row
headers = [th.inner_text().strip() for th in page.query_selector_all("table.table th")]
print(headers[:4])
# ['Team Name', 'Year', 'Wins', 'Losses']

Those four names are the page's own labels for the columns you are reading, which is all this check is for: the CSV written at the end takes its header row from the fieldnames list, not from these. There is no <thead> on this page, so the selector reaches straight into the table for its <th> elements rather than qualifying through one.

Step 3: follow the next link until it disappears

Wrap the row-reading in a loop: read the current page, click the link matching a[aria-label="Next"], wait for the new rows, repeat — and stop either when that link is no longer on the page, or when a cap you set yourself is reached:

step 3 walk the pages, with a cap
MAX_PAGES = 3  # demo cap; see the note below the full script

pages_done = 0
while True:
    # ...read rows on this page as in step 1...
    pages_done += 1
    if pages_done >= MAX_PAGES:
        break
    next_link = page.query_selector('a[aria-label="Next"]')
    if next_link is None:
        break
    next_link.click()
    page.wait_for_load_state()
    page.wait_for_selector("tr.team")
    time.sleep(1.0)  # be polite between page loads

Three things worth noticing. The cap is checked before the next-link lookup, so the script never fetches a page it is not going to read. The next_link is None check is what stops the loop cleanly on page 24, where that link genuinely is not in the document — no guessing at a page count. And the one-second sleep between clicks is there because a script that hammers a page as fast as the network allows is a bad guest even on a sandbox built to be scraped.

The full script

Put the three pieces together and you get the script actually run to produce the output below. It is capped at MAX_PAGES = 3 so the demo finishes in a few seconds — 75 rows instead of roughly 582. To scrape every page, either set MAX_PAGES to a bigger number or change the loop's stopping condition to drop the cap check entirely and rely solely on the next-link disappearing.

tutorial_scrape.py complete, copy-pasteable
"""Scrape the hockey team table at scrapethissite.com/pages/forms/ to CSV.

Tutorial script for https://scrapejig.com/guides/export-html-table-to-csv

Setup:
    pip3 install playwright
    python3 -m playwright install chromium

Run:
    python3 tutorial_scrape.py
"""

import csv
import time

from playwright.sync_api import sync_playwright

START_URL = "https://www.scrapethissite.com/pages/forms/?page_num=1"
MAX_PAGES = 3  # demo cap so this runs fast; see the note below to remove it
DELAY_SECONDS = 1.0


def scrape_page(page):
    """Read every row on the table currently loaded."""
    headers = [th.inner_text().strip() for th in page.query_selector_all("table.table th")]
    rows = []
    for tr in page.query_selector_all("tr.team"):
        rows.append({
            "team": tr.query_selector("td.name").inner_text().strip(),
            "year": tr.query_selector("td.year").inner_text().strip(),
            "wins": tr.query_selector("td.wins").inner_text().strip(),
            "losses": tr.query_selector("td.losses").inner_text().strip(),
        })
    return headers, rows


def main():
    all_rows = []
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()
        page.goto(START_URL)
        page.wait_for_selector("tr.team")

        pages_done = 0
        while True:
            _, rows = scrape_page(page)
            all_rows += rows
            pages_done += 1
            print(f"page {pages_done}: {len(rows)} rows")

            if pages_done >= MAX_PAGES:
                break

            next_link = page.query_selector('a[aria-label="Next"]')
            if next_link is None:
                break

            next_link.click()
            page.wait_for_load_state()
            page.wait_for_selector("tr.team")
            time.sleep(DELAY_SECONDS)

        browser.close()

    with open("tutorial_output.csv", "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=["team", "year", "wins", "losses"])
        writer.writeheader()
        writer.writerows(all_rows)

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


if __name__ == "__main__":
    main()

Running it

Installed with pip3 install playwright && python3 -m playwright install chromium, then run directly. This is the real terminal output from the script above, unedited:

$ python3 tutorial_scrape.py terminal output
page 1: 25 rows
page 2: 25 rows
page 3: 25 rows
Scraped 75 rows -> tutorial_output.csv

And the first three lines of tutorial_output.csv:

tutorial_output.csv first 3 lines
team,year,wins,losses
Boston Bruins,1990,44,24
Buffalo Sabres,1990,31,30

75 rows: 25 from each of the three pages the cap allowed, which is exactly what the loop in Step 3 was built to guarantee. Remove the cap and the same script will keep going through all 24 pages — roughly 582 rows — at one page per second plus load time, so well under a minute rather than a few seconds.

Or: don't write it.

Scrapejig generates this exact kind of file from a few clicks in the side panel: you click one cell in each column you want, it infers the repeating row selector on its own, you choose Pagination as the flow and point the picker at the next link, then press Export Python. Picking and previewing are free; Export Python is the one thing the £29 one-off licence unlocks — see how the flow modes work and what the exported file contains, or go straight to pricing.

Here is the file Scrapejig exports for this page, unedited — same table, same next link, the same three-page cap set in the panel:

scrape_www_scrapethissite_com.py Scrapejig 1.0.0 export
"""Scrape www.scrapethissite.com — generated by Scrapejig on 2026-09-10.

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

Run:
    python3 scrape_www_scrapethissite_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_www_scrapethissite_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://www.scrapethissite.com/pages/forms/?page_num=1"
HEADLESS = True
OUTPUT_BASE = "scrape_output"
DELAY_SECONDS = 1.0  # pause between page loads; raise to be gentler
ITEM_SELECTOR = "tr.team"
FIELDS = ["team", "year", "wins", "losses"]
NEXT_SELECTOR = "a[aria-label=\"Next\"]"
MAX_PAGES = 3  # set in the panel; change to None to scrape every page


def extract_item(item):
    """Pull one row's fields from a single item element."""
    return {
        "team": text(item, "td.name"),
        "year": text(item, "td.year"),
        "wins": text(item, "td.wins"),
        "losses": text(item, "td.losses"),
    }


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):
    """Walk every page via the next link, collecting items as we go."""
    open_start_page(page)
    rows = []
    pages_done = 0
    while True:
        rows += rows_on(page)
        pages_done += 1
        if MAX_PAGES is not None and pages_done >= MAX_PAGES:
            break
        next_link = page.query_selector(NEXT_SELECTOR)
        if next_link is None:
            break
        previous_url = page.url
        try:
            next_link.click()
            page.wait_for_load_state()
            page.wait_for_selector(ITEM_SELECTOR)
        except Exception as error:
            # Usually a consent banner over the link: a fresh profile has agreed to nothing, so
            # the overlay is there and it eats the click (see the storage_state note in main()).
            # The pages already walked are still yours; losing them to the last click is not.
            print(
                f"Stopped paginating: {error} — keeping the {len(rows)} rows collected so far.",
                file=sys.stderr,
            )
            break
        time.sleep(DELAY_SECONDS)
        # A next arrow that never disappears would loop forever, so stop if the URL
        # did not move. Site paginates without changing it? Delete this, set MAX_PAGES.
        if page.url == previous_url:
            break
    return rows


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://www.scrapethissite.com/pages/forms/?page_num=1"
        # 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()

The shape is the same script — open the start page, read the rows, follow the next link, stop — but the generated file does more than the tutorial version above. It checks the HTTP status of the start page and tells you plainly whether the URL is wrong or you have hit a bot wall, rather than failing thirty seconds later with a bare timeout. It catches a failed click on the next link and keeps every row already collected instead of losing the run to one bad page. It drops any row whose columns all came back empty, on the basis that an item selector occasionally matches something that is not really a row. And it writes both a CSV and a JSON copy of the same data, with a docstring at the top explaining setup, the virtual-environment fix for externally-managed-environment, and the Windows launcher — none of which this tutorial's hand-written version bothers with.