Scrapejig

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

How to scrape product prices to CSV (and re-run it next week)

A one-off export of today's prices is rarely the actual job. The job is watching prices over time, which means running the same scrape again next week, and the week after that — so what you want out of this is not a spreadsheet, it is a file: something you can re-run whenever you like and get a fresh CSV out of. Below is a complete, working example of that file, built for a practice shop so you can run every line of it yourself, followed by a look at what running it twice, a week apart, actually looks like.

The page you're scraping.

books.toscrape.com is a public site built specifically for practising scraping. Nothing on it is a real shop, and the prices are invented for the exercise — worth saying up front, because the whole point of this guide is to leave you able to point the same script at somewhere real. Its front page lists twenty books, each one a card. Every field this guide wants — title, price, and a link to the product page — lives inside the same repeating element, so this is the shape to learn even though the fields will differ site to site.

One card looks like this, trimmed to what matters:

books.toscrape.com one product card
<article class="product_pod">
  <h3><a href="catalogue/a-light-in-the-attic_1000/index.html"
         title="A Light in the Attic">A Light in the ...</a></h3>
  <p class="price_color">£51.77</p>
</article>

Twenty of those, one after another, laid out identically. That repetition is what a scraper is for: find the thing that repeats, then read the same handful of fields out of each one.

The script.

Below is the whole thing, and it is short enough to read in one sitting. No classes, no argument parsing, no dependency beyond Playwright and the standard library — a scraper this size does not need any of that, and adding it would just be more places for something to go wrong. A few points in it are worth slowing down for before you read the file straight through.

The repeating card, then the fields inside it

There are two selectors doing two different jobs. page.query_selector_all("article.product_pod") finds every card on the page currently loaded. Then, for each card, a second selector — card.query_selector("h3 a"), card.query_selector("p.price_color") — runs inside that one card, not the whole page. Get this the wrong way round and you will collect the first book's price for every row, because a page-wide selector for p.price_color just returns the first match, over and over.

An attribute, not just the text

The visible link text is often not the whole title — this site truncates long ones with an ellipsis, so "Sapiens: A Brief History ..." is what you would get from .inner_text(). The full title is sitting right there anyway, in the link's title attribute, which is exactly what it is for. link.get_attribute("title") reads it untruncated. It is a small thing, but it is the difference between a script that happens to work on the books you tried and one that is quietly dropping the ends off long titles.

A relative link, and why the base matters

The href in the markup above is relative: catalogue/a-light-in-the-attic_1000/index.html. Resolving it against the site's start URL works on page one. It stops working from page two onward, because page two of this listing lives at https://books.toscrape.com/catalogue/page-2.html, and its product links are written relative to that page — just in-her-wake_980/index.html, with no catalogue/ in front. Resolve that against the start URL and you get a broken link; resolve it against page.url, the address of whatever page is actually loaded right now, and it comes out right on every page. That is what urljoin(page.url, link.get_attribute("href")) is doing, and it is worth using even when you think the site never nests its pages that way — plenty do, and it costs nothing on the ones that don't.

The price stays a string

The price cell reads £51.77, currency symbol included, and this script writes it to the CSV exactly like that rather than parsing it into a number. That is deliberate, not laziness. A parse step buried inside the scraper is a second thing that can silently produce the wrong answer — a comma used as a decimal separator, a "was £60, now £45" pair, an "Out of stock" row with no number in it at all — and when it does, you do not find out until a downstream chart looks wrong. Reading the page and turning text into numbers you can do maths on are two different jobs. Keep the scraper honest about what it actually saw, and do the parsing afterwards, once, somewhere you can watch it fail loudly instead of somewhere it fails quietly.

Following the next page

The link to the next page is li.next a, and this script clicks it, waits for the new page to settle, and repeats — up to MAX_PAGES, capped at 3 here so the demo run below finishes in a few seconds rather than walking all fifty pages of this catalogue. Real sites vary a good deal in how "next" behaves, what stops a pagination loop, and what to do when a next link is there but dead — that is its own subject, and the flow modes guide covers it properly, with the other three ways a listing can continue beyond a next link.

A delay between pages

Playwright will click a link and load the following page about as fast as your connection allows, and nothing here forces it to slow down except DELAY_SECONDS. This site doesn't even publish a robots.txt — fetching one gets a plain 404 — so there is no crawl policy to check here, but most real shops do have one, and it is worth a look before you point a script at somewhere that matters: it tells you what the site owner is willing to have crawled, and how fast. Either way, a script that hits pages back to back, with no pause, reads to a server exactly like an attack rather than one visitor browsing quickly. A one-second pause between pages costs you almost nothing and is the whole difference.

Writing the CSV

The last piece is ordinary: collect every row into a list of dictionaries as you go, then hand the whole list to csv.DictWriter once, at the end, rather than opening the file and writing a row at a time inside the scraping loop. It keeps the two concerns — getting the data, and saving it — apart, which matters more once the script grows past this size.

Here is the complete file:

tutorial.py 65 lines
"""Scrape book titles, prices and URLs from books.toscrape.com.

books.toscrape.com is a public practice shop built for scraping tutorials.
The books are real; the prices are made up for the exercise.

Run:
    python3 tutorial.py
"""

import csv
import time
from urllib.parse import urljoin

from playwright.sync_api import sync_playwright

START_URL = "https://books.toscrape.com/"
MAX_PAGES = 3        # demo cap — there are 50 pages in total, 1,000 books
DELAY_SECONDS = 1.0  # pause between page loads; be polite to the site


def scrape_page(page):
    """Read every book card on the page currently loaded."""
    rows = []
    for card in page.query_selector_all("article.product_pod"):
        link = card.query_selector("h3 a")
        rows.append({
            "title": link.get_attribute("title"),
            "price": card.query_selector("p.price_color").inner_text(),
            "url": urljoin(page.url, link.get_attribute("href")),
        })
    return rows


def main():
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        page.goto(START_URL)

        rows = []
        pages_done = 0
        while True:
            rows += scrape_page(page)
            pages_done += 1
            if pages_done >= MAX_PAGES:
                break
            next_link = page.query_selector("li.next a")
            if next_link is None:
                break
            next_link.click()
            page.wait_for_load_state()
            time.sleep(DELAY_SECONDS)

        browser.close()

    with open("prices.csv", "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=["title", "price", "url"])
        writer.writeheader()
        writer.writerows(rows)

    print(f"Wrote {len(rows)} rows to prices.csv")


if __name__ == "__main__":
    main()

Two dependencies, both worth having installed before you run it: pip3 install playwright, then python3 -m playwright install chromium to fetch a browser for it to drive.

Running it.

This is a real run, on the real site, not a transcript written up after the fact. Three pages, twenty books each, sixty rows out:

terminal python3 tutorial.py
$ python3 tutorial.py
Wrote 60 rows to prices.csv

And the first three lines of the file it wrote:

prices.csv first 3 lines
title,price,url
A Light in the Attic,£51.77,https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
Tipping the Velvet,£53.74,https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html

Sixty rows because twenty cards times three pages is sixty — the number to check against if you raise MAX_PAGES or point this at a different listing. Note the URL column is a full, absolute address on every row, page two and three included, which is the urljoin(page.url, ...) line paying for itself.

Re-running it next week.

The file above is not the point. Running it again is. The whole reason to keep it as a script rather than a one-off spreadsheet is that next week you run the exact same command and get a new CSV, and the interesting thing is never the file on its own — it is what changed between this one and last week's. A plain diff answers that without any extra tooling:

terminal diff prices.csv prices-next-week.csv
$ diff prices.csv prices-next-week.csv
2c2
< A Light in the Attic,£51.77,https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
---
> A Light in the Attic,£48.99,https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html

One book, one price, one line changed — that is a price drop you would otherwise have had to notice by eye across sixty rows, or six hundred. (This particular diff is a worked example: books.toscrape.com's fake prices don't move week to week, so the second file here has one price edited by hand to show what a real change looks like in the output. Point the script at a shop whose prices actually move and the same command finds it for real.) If you would rather stay inside Python, the standard library's own csv module does the same comparison in a few lines — read both files with csv.DictReader, key each row by its URL, and compare the price field for any URL that appears in both.

Getting from "run it by hand" to "runs on its own" is one cron line. On macOS or Linux, crontab -e and a line like this runs it every Monday at eight in the morning:

crontab weekly, Monday 08:00
0 8 * * 1 cd /home/you/prices && /home/you/prices/.venv/bin/python tutorial.py >> prices.log 2>&1

The absolute path to the interpreter matters here — cron does not read your shell profile, so a bare python3 may not resolve to anything at all. Keep last week's CSV before this one overwrites it, whether that is renaming it with a date in the filename or copying it aside in the same cron line, or there is nothing left to diff against.

Or: don't write it.

Writing the script above is exactly what Scrapejig automates. Click the title, the price and the link on the shop's page, choose Pagination and point the panel at the Next link, then press Export Python — no editor, no terminal, until you actually want to run the file. Picking fields and previewing them is free, and if one page of prices is all you need, the panel's Copy CSV button gives you that page with a free key and no payment; Export Python is the one thing the £29 one-off licence unlocks. Flow modes covers the four ways a listing can continue, the exported script walks the generated file function by function, and pricing has the rest.

Here is the file Scrapejig exports for this exact page, unedited — the same recipe as above (title, price, link, pagination capped at 3 pages) run through the real generator:

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

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

Run:
    python3 scrape_books_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_books_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 urllib.parse import urljoin

from playwright.sync_api import sync_playwright

START_URL = "https://books.toscrape.com/"
HEADLESS = True
OUTPUT_BASE = "scrape_output"
DELAY_SECONDS = 1.0  # pause between page loads; raise to be gentler
ITEM_SELECTOR = "article.product_pod"
FIELDS = ["title", "price", "url"]
NEXT_SELECTOR = "li.next a"
MAX_PAGES = 3  # set in the panel; change to None to scrape every page


def extract_item(item, base):
    """Pull one row's fields from a single item element.

    base is the URL of the page this item came from. Relative links resolve
    against it, which from page 2 onward is no longer START_URL.
    """
    return {
        "title": attr(item, "h3 a", "title", base),
        "price": text(item, "p.price_color"),
        "url": attr(item, "h3 a", "href", base),
    }


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 attr(el, selector, name, base):
    node = el.query_selector(selector)
    try:
        value = (node.get_attribute(name) or "") if node else ""
    except Exception:
        return ""
    if value and name in ("href", "src"):
        value = urljoin(base, value)
    return value


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, page.url)
        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://books.toscrape.com/"
        # 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()

A few things in that file the tutorial script above doesn't do. It writes scrape_output.json alongside the CSV, the same rows in a format that is easier to feed into another program. It checks the HTTP status when it loads the start page and says plainly on standard error when that page came back as an error or a block, rather than just timing out with no explanation. And a failed click on the next link doesn't lose the run — it prints what went wrong and keeps every row already collected, instead of the whole script dying on page three with nothing saved.