Cases / #28 · 2026-10-09 · Easy
Scrape a catalogue page by page: structured data from rendered HTML
Every book on two pages of a catalogue — title, price, star rating and availability — as clean JSON, parsed from the rendered DOM in one evaluate call per page.
Run on production on 2026-10-09: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
A catalogue page shows 20 items with prices in formatted strings ("£51.77"), star ratings encoded in CSS class names ("star-rating Three") and availability wrapped in whitespace. You want records with real numbers, integers and booleans — and you want the next page too, the way a visitor gets there.
What we used, and why
| What | Why |
|---|---|
chromium, headless: "new" | One session, nothing browser-specific. |
page.evaluate with querySelectorAll | All 20 books in one round trip, parsed inside the page. |
parseFloat(text.replace(/[^0-9.]/g, "")) | Strips the currency symbol and returns a number. |
The class list of .star-rating | The rating is a word (One … Five) in a class name; a lookup table turns it into 1–5. |
Clicking li.next a | Follows pagination with the site's own link instead of building URLs. |
How it works
- Open the first page of books.toscrape.com and extract all 20 books: title, price (number), rating (1–5), in stock (boolean).
- Click "next" and wait for page 2.
- Extract page 2 the same way and print one JSON document with the count, the records and the time taken.
The code
The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:
CDPFLEET_API_KEY— your API key (dashboard → API keys)PROXY_URL— your proxy, e.g.http://user:[email protected]:8000
// npm install [email protected]
// env: CDPFLEET_API_KEY, PROXY_URL
import { chromium } from 'playwright';
const KEY = process.env.CDPFLEET_API_KEY;
const START = 'https://books.toscrape.com/';
const STARS = { One: 1, Two: 2, Three: 3, Four: 4, Five: 5 };
const EXTRACT_JS = `() => [...document.querySelectorAll('article.product_pod')].map(el => {
const stars = { One: 1, Two: 2, Three: 3, Four: 4, Five: 5 };
const ratingClass = [...el.querySelector('.star-rating').classList].find(c => c !== 'star-rating');
return {
title: el.querySelector('h3 a').getAttribute('title'),
price: parseFloat(el.querySelector('.price_color').textContent.replace(/[^0-9.]/g, '')),
rating: stars[ratingClass] || 0,
in_stock: el.querySelector('.availability').textContent.trim().toLowerCase().includes('in stock'),
};
})`;
const res = await fetch('https://starter.cdpfleet.com/chromium/session', {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify({ proxy: process.env.PROXY_URL, headless: 'new' }),
});
if (!res.ok) throw new Error(`launch ${res.status} ${await res.text()}`);
const { wsUrl } = await res.json();
const browser = await chromium.connect(wsUrl, { headers: { 'x-api-key': KEY } });
try {
const page = await browser.newPage();
const t = Date.now();
const books = [];
await page.goto(START, { timeout: 60000 });
books.push(...await page.evaluate(`(${EXTRACT_JS})()`));
const next = page.locator('li.next a');
if (await next.count() > 0) {
await next.click();
await page.waitForLoadState('domcontentloaded');
books.push(...await page.evaluate(`(${EXTRACT_JS})()`));
}
console.log(JSON.stringify({
pages_scraped: 2,
total_books: books.length,
books,
seconds: Math.round((Date.now() - t) / 10) / 100,
}, null, 2));
} finally {
await browser.close();
}
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import json
import os
import time
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
START = "https://books.toscrape.com/"
EXTRACT_JS = """() => [...document.querySelectorAll('article.product_pod')].map(el => {
const stars = { One: 1, Two: 2, Three: 3, Four: 4, Five: 5 };
const ratingClass = [...el.querySelector('.star-rating').classList].find(c => c !== 'star-rating');
return {
title: el.querySelector('h3 a').getAttribute('title'),
price: parseFloat(el.querySelector('.price_color').textContent.replace(/[^0-9.]/g, '')),
rating: stars[ratingClass] || 0,
in_stock: el.querySelector('.availability').textContent.trim().toLowerCase().includes('in stock'),
};
})"""
res = requests.post("https://starter.cdpfleet.com/chromium/session",
headers={"x-api-key": KEY},
json={"proxy": os.environ["PROXY_URL"], "headless": "new"}, timeout=60)
if not res.ok:
raise SystemExit(f"launch {res.status_code} {res.text}")
with sync_playwright() as p:
browser = p.chromium.connect(res.json()["wsUrl"], headers={"x-api-key": KEY})
try:
page = browser.new_page()
t = time.time()
books = []
page.goto(START, timeout=60000)
books.extend(page.evaluate(EXTRACT_JS))
nxt = page.locator("li.next a")
if nxt.count() > 0:
nxt.click()
page.wait_for_load_state("domcontentloaded")
books.extend(page.evaluate(EXTRACT_JS))
print(json.dumps({
"pages_scraped": 2,
"total_books": len(books),
"books": books,
"seconds": round(time.time() - t, 2),
}, indent=2, ensure_ascii=False))
finally:
browser.close()
// Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// Run with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1. env: CDPFLEET_API_KEY, PROXY_URL
import com.google.gson.*;
import com.microsoft.playwright.*;
import java.net.URI;
import java.net.http.*;
import java.util.*;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final String START = "https://books.toscrape.com/";
static final Gson GSON = new GsonBuilder().serializeNulls().create();
static final String EXTRACT_JS = """
() => [...document.querySelectorAll('article.product_pod')].map(el => {
const stars = { One: 1, Two: 2, Three: 3, Four: 4, Five: 5 };
const ratingClass = [...el.querySelector('.star-rating').classList].find(c => c !== 'star-rating');
return {
title: el.querySelector('h3 a').getAttribute('title'),
price: parseFloat(el.querySelector('.price_color').textContent.replace(/[^0-9.]/g, '')),
rating: stars[ratingClass] || 0,
in_stock: el.querySelector('.availability').textContent.trim().toLowerCase().includes('in stock'),
};
})""";
public static void main(String[] args) throws Exception {
String body = "{\"proxy\": " + GSON.toJson(System.getenv("PROXY_URL")) + ", \"headless\": \"new\"}";
HttpResponse<String> res = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/chromium/session"))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw new RuntimeException("launch: " + res.statusCode() + " " + res.body());
String wsUrl = JsonParser.parseString(res.body()).getAsJsonObject().get("wsUrl").getAsString();
try (Playwright playwright = Playwright.create()) {
Browser browser = playwright.chromium().connect(wsUrl, new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
try {
Page page = browser.newPage();
long t = System.currentTimeMillis();
JsonArray books = new JsonArray();
page.navigate(START, new Page.NavigateOptions().setTimeout(60000));
for (JsonElement e : GSON.toJsonTree(page.evaluate(EXTRACT_JS)).getAsJsonArray()) books.add(e);
Locator next = page.locator("li.next a");
if (next.count() > 0) {
next.click();
page.waitForLoadState(com.microsoft.playwright.options.LoadState.DOMCONTENTLOADED);
for (JsonElement e : GSON.toJsonTree(page.evaluate(EXTRACT_JS)).getAsJsonArray()) books.add(e);
}
JsonObject out = new JsonObject();
out.addProperty("pages_scraped", 2);
out.addProperty("total_books", books.size());
out.add("books", books);
out.addProperty("seconds", Math.round((System.currentTimeMillis() - t) / 10.0) / 100.0);
System.out.println(new GsonBuilder().serializeNulls().setPrettyPrinting().disableHtmlEscaping().create().toJson(out));
} finally {
browser.close();
}
}
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
using System.Net.Http.Json;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Playwright;
const string Start = "https://books.toscrape.com/";
const string ExtractJs = """
() => [...document.querySelectorAll('article.product_pod')].map(el => {
const stars = { One: 1, Two: 2, Three: 3, Four: 4, Five: 5 };
const ratingClass = [...el.querySelector('.star-rating').classList].find(c => c !== 'star-rating');
return {
title: el.querySelector('h3 a').getAttribute('title'),
price: parseFloat(el.querySelector('.price_color').textContent.replace(/[^0-9.]/g, '')),
rating: stars[ratingClass] || 0,
in_stock: el.querySelector('.availability').textContent.trim().toLowerCase().includes('in stock'),
};
})
""";
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
var res = await http.PostAsJsonAsync("https://starter.cdpfleet.com/chromium/session",
new { proxy = Environment.GetEnvironmentVariable("PROXY_URL"), headless = "new" });
if (!res.IsSuccessStatusCode) throw new Exception($"launch: {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
var wsUrl = (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("wsUrl").GetString()!;
using var playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.ConnectAsync(wsUrl, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
try
{
var page = await browser.NewPageAsync();
var sw = System.Diagnostics.Stopwatch.StartNew();
var books = new JsonArray();
await page.GotoAsync(Start, new() { Timeout = 60000 });
foreach (var el in (await page.EvaluateAsync<JsonElement>(ExtractJs)).EnumerateArray())
books.Add(JsonNode.Parse(el.GetRawText()));
var next = page.Locator("li.next a");
if (await next.CountAsync() > 0)
{
await next.ClickAsync();
await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
foreach (var el in (await page.EvaluateAsync<JsonElement>(ExtractJs)).EnumerateArray())
books.Add(JsonNode.Parse(el.GetRawText()));
}
var result = new JsonObject
{
["pages_scraped"] = 2,
["total_books"] = books.Count,
["books"] = books,
["seconds"] = Math.Round(sw.Elapsed.TotalSeconds, 2),
};
Console.WriteLine(result.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
}
finally
{
await browser.CloseAsync();
}
// go get github.com/playwright-community/[email protected]
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY, PROXY_URL
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"time"
"github.com/playwright-community/playwright-go"
)
var key = os.Getenv("CDPFLEET_API_KEY")
const start = "https://books.toscrape.com/"
const extractJS = `() => [...document.querySelectorAll('article.product_pod')].map(el => {
const stars = { One: 1, Two: 2, Three: 3, Four: 4, Five: 5 };
const ratingClass = [...el.querySelector('.star-rating').classList].find(c => c !== 'star-rating');
return {
title: el.querySelector('h3 a').getAttribute('title'),
price: parseFloat(el.querySelector('.price_color').textContent.replace(/[^0-9.]/g, '')),
rating: stars[ratingClass] || 0,
in_stock: el.querySelector('.availability').textContent.trim().toLowerCase().includes('in stock'),
};
})`
func launch(name string, options map[string]any) (map[string]any, error) {
body, _ := json.Marshal(options)
req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+name+"/session", bytes.NewReader(body))
req.Header.Set("x-api-key", key)
req.Header.Set("content-type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(res.Body)
return nil, fmt.Errorf("launch %s: %s %s", name, res.Status, msg)
}
var session map[string]any
return session, json.NewDecoder(res.Body).Decode(&session)
}
type book struct {
Title string `json:"title"`
Price float64 `json:"price"`
Rating int `json:"rating"`
InStock bool `json:"in_stock"`
}
type output struct {
PagesScraped int `json:"pages_scraped"`
TotalBooks int `json:"total_books"`
Books []book `json:"books"`
Seconds float64 `json:"seconds"`
}
func extractBooks(page playwright.Page) []book {
v, err := page.Evaluate(extractJS)
if err != nil {
log.Fatal(err)
}
var books []book
for _, item := range v.([]any) {
m := item.(map[string]any)
b := book{Title: m["title"].(string), InStock: m["in_stock"].(bool)}
switch p := m["price"].(type) {
case float64:
b.Price = p
}
switch r := m["rating"].(type) {
case float64:
b.Rating = int(r)
case int:
b.Rating = r
}
books = append(books, b)
}
return books
}
func main() {
session, err := launch("chromium", map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": "new"})
if err != nil {
log.Fatal(err)
}
pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
if err != nil {
log.Fatal(err)
}
defer pw.Stop()
browser, err := pw.Chromium.Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
if err != nil {
log.Fatal(err)
}
defer browser.Close()
page, err := browser.NewPage()
if err != nil {
log.Fatal(err)
}
t := time.Now()
if _, err := page.Goto(start, playwright.PageGotoOptions{Timeout: playwright.Float(60000)}); err != nil {
log.Fatal(err)
}
books := extractBooks(page)
next := page.Locator("li.next a")
count, err := next.Count()
if err != nil {
log.Fatal(err)
}
if count > 0 {
if err := next.Click(); err != nil {
log.Fatal(err)
}
if err := page.WaitForLoadState(playwright.PageWaitForLoadStateOptions{State: playwright.LoadStateDomcontentloaded}); err != nil {
log.Fatal(err)
}
books = append(books, extractBooks(page)...)
}
out := output{
PagesScraped: 2,
TotalBooks: len(books),
Books: books,
Seconds: math.Round(time.Since(t).Seconds()*100) / 100,
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
enc.Encode(out)
fmt.Print(buf.String())
}
What we got
| Pages | Books | Prices | Ratings | In stock | Seconds |
|---|---|---|---|---|---|
| 2 | 40 | £12.84 – £57.25 | 1, 2, 3, 4, 5 | 40/40 | 10.18 |
From the Node.js run on 2026-10-09. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.
Raw output (Node.js)
{
"pages_scraped": 2,
"total_books": 40,
"books": [
{
"title": "A Light in the Attic",
"price": 51.77,
"rating": 3,
"in_stock": true
},
{
"title": "Tipping the Velvet",
"price": 53.74,
"rating": 1,
"in_stock": true
},
{
"title": "Soumission",
"price": 50.1,
"rating": 1,
"in_stock": true
},
{
"title": "Sharp Objects",
"price": 47.82,
"rating": 4,
"in_stock": true
},
{
"title": "Sapiens: A Brief History of Humankind",
"price": 54.23,
"rating": 5,
"in_stock": true
},
{
"title": "The Requiem Red",
"price": 22.65,
"rating": 1,
"in_stock": true
},
{
"title": "The Dirty Little Secrets of Getting Your Dream Job",
"price": 33.34,
"rating": 4,
"in_stock": true
},
{
"title": "The Coming Woman: A Novel Based on the Life of the Infamous Feminist, Victoria Woodhull",
"price": 17.93,
"rating": 3,
"in_stock": true
},
{
"title": "The Boys in the Boat: Nine Americans and Their Epic Quest for Gold at the 1936 Berlin Olympics",
"price": 22.6,
"rating": 4,
"in_stock": true
},
{
"title": "The Black Maria",
"price": 52.15,
"rating": 1,
"in_stock": true
},
{
"title": "Starving Hearts (Triangular Trade Trilogy, #1)",
"price": 13.99,
"rating": 2,
"in_stock": true
},
{
"title": "Shakespeare's Sonnets",
"price": 20.66,
"rating": 4,
"in_stock": true
},
{
"title": "Set Me Free",
"price": 17.46,
"rating": 5,
"in_stock": true
},
{
"title": "Scott Pilgrim's Precious Little Life (Scott Pilgrim #1)",
"price": 52.29,
"rating": 5,
"in_stock": true
},
{
"title": "Rip it Up and Start Again",
"price": 35.02,
"rating": 5,
"in_stock": true
},
{
"title": "Our Band Could Be Your Life: Scenes from the American Indie Underground, 1981-1991",
"price": 57.25,
"rating": 3,
"in_stock": true
},
{
"title": "Olio",
"price": 23.88,
"rating": 1,
"in_stock": true
},
{
"title": "Mesaerion: The Best Science Fiction Stories 1800-1849",
"price": 37.59,
"rating": 1,
"in_stock": true
},
{
"title": "Libertarianism for Beginners",
"price": 51.33,
"rating": 2,
"in_stock": true
},
{
"title": "It's Only the Himalayas",
"price": 45.17,
"rating": 2,
"in_stock": true
},
{
"title": "In Her Wake",
"price": 12.84,
"rating": 1,
"in_stock": true
},
{
"title": "How Music Works",
"price": 37.32,
"rating": 2,
"in_stock": true
},
{
"title": "Foolproof Preserving: A Guide to Small Batch Jams, Jellies, Pickles, Condiments, and More: A Foolproof Guide to Making Small Batch Jams, Jellies, Pickles, Condiments, and More",
"price": 30.52,
"rating": 3,
"in_stock": true
},
{
"title": "Chase Me (Paris Nights #2)",
"price": 25.27,
"rating": 5,
"in_stock": true
},
{
"title": "Black Dust",
"price": 34.53,
"rating": 5,
"in_stock": true
},
{
"title": "Birdsong: A Story in Pictures",
"price": 54.64,
"rating": 3,
"in_stock": true
},
{
"title": "America's Cradle of Quarterbacks: Western Pennsylvania's Football Factory from Johnny Unitas to Joe Montana",
"price": 22.5,
"rating": 3,
"in_stock": true
},
{
"title": "Aladdin and His Wonderful Lamp",
"price": 53.13,
"rating": 3,
"in_stock": true
},
{
"title": "Worlds Elsewhere: Journeys Around Shakespeare’s Globe",
"price": 40.3,
"rating": 5,
"in_stock": true
},
{
"title": "Wall and Piece",
"price": 44.18,
"rating": 4,
"in_stock": true
},
{
"title": "The Four Agreements: A Practical Guide to Personal Freedom",
"price": 17.66,
"rating": 5,
"in_stock": true
},
{
"title": "The Five Love Languages: How to Express Heartfelt Commitment to Your Mate",
"price": 31.05,
"rating": 3,
"in_stock": true
},
{
"title": "The Elephant Tree",
"price": 23.82,
"rating": 5,
"in_stock": true
},
{
"title": "The Bear and the Piano",
"price": 36.89,
"rating": 1,
"in_stock": true
},
{
"title": "Sophie's World",
"price": 15.94,
"rating": 5,
"in_stock": true
},
{
"title": "Penny Maybe",
"price": 33.29,
"rating": 3,
"in_stock": true
},
{
"title": "Maude (1883-1993):She Grew Up with the country",
"price": 18.02,
"rating": 2,
"in_stock": true
},
{
"title": "In a Dark, Dark Wood",
"price": 19.63,
"rating": 1,
"in_stock": true
},
{
"title": "Behind Closed Doors",
"price": 52.22,
"rating": 4,
"in_stock": true
},
{
"title": "You can't bury them all: Poems",
"price": 33.63,
"rating": 2,
"in_stock": true
}
],
"seconds": 10.18
}Takeaways
- One
evaluateper page extracts all 20 records — parsing in the page avoids 20 round trips over the session's connection. - Ratings in class names are a common pattern: the word in
star-rating Threeis sturdier than counting star icons. - Paginate with the site's own link: clicking
li.next asurvives URL scheme changes and exercises the path a visitor takes. - Validate types at the source: every price parsed to a number and every rating to 1–5, or the run fails — catch layout changes on day one.