Cases / #61 · 2026-10-18 · Medium
Borrow the page's token and read its paginated API directly
Log in once in a remote browser, find the bearer token the app sends to its own API, and fetch every page of the API from inside the page: the same records as clicking through every page, with 16 fields each instead of 5 columns, in about a second instead of many.
Run on production on 2026-10-18: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
Many sites behind a login are single-page apps: the table you see is drawn from a JSON API, and the app proves who you are with a token on every call. Scraping the table means clicking through pages and parsing cells; the API has the same records with every field, but answers 401 without the token. The token is already in the browser after you log in — the question is where to find it, how long it lasts, and how to use it so the requests still come from the browser. The site here is Toolshop (practicesoftwaretesting.com), a public demo shop built for practising test automation, with its published demo account.
What we used, and why
| What | Why |
|---|---|
chromium, headless: "new" | One session. The login, the token and the API calls all live in it. |
page.setDefaultTimeout(90000) | Through a rotating residential exit the app sometimes took 30 s to draw its login form — longer than Playwright's default 30 s for an action. |
localStorage.getItem('auth-token') | Where this app keeps its token after login (a JWT). Other apps use a cookie, sessionStorage or memory only. |
page.waitForRequest(…) + allHeaders() | Catches the app's own API call and reads its Authorization header — this works even when the token is never stored anywhere you can read. |
page.evaluate(() => fetch(url, { headers: { authorization } })) | Calls the API from inside the page, so the requests come from the browser, through the session's proxy, with the site's own origin — not from a different HTTP stack (four ways to make a request). |
How it works
- Open the login page and sign in with the site's demo account.
- Read the token from
localStorage, open the invoices page and catch the app's first/invoicesrequest to compare itsAuthorizationheader; decode the JWT's lifetime. - Click Next through all pages, reading the table each time, and count the app's
/invoicescalls. - Fetch every page of
/invoices?page=Nfrom inside the page with the token: one by one, then all at once; then once without the token. - Compare the records both ways collected.
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 SITE = 'https://practicesoftwaretesting.com'; // Toolshop: a public demo shop built for test automation
const API = 'https://api.practicesoftwaretesting.com';
const LOGIN = { email: '[email protected]', password: 'welcome01' }; // the site's published demo account
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async function launch(body) {
for (let attempt = 1; ; attempt++) {
const res = await fetch('https://starter.cdpfleet.com/chromium/session', {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify(body),
});
if (res.ok) return res.json();
if (res.status !== 503 || attempt === 5) throw new Error(`launch ${res.status} ${await res.text()}`);
await new Promise((r) => setTimeout(r, (Number(res.headers.get('retry-after')) || 5) * 1000));
}
}
const { wsUrl } = await launch({ proxy: process.env.PROXY_URL, headless: 'new', record: false });
const browser = await chromium.connect(wsUrl, { headers: { 'x-api-key': KEY } });
try {
const page = await browser.newPage();
// The app can take 30 s to render through a rotating residential exit: wait up to 90 s.
page.setDefaultTimeout(90000);
// 1. Log in like a person; the site stores its token and sends it with every API call.
await page.goto(`${SITE}/auth/login`);
await page.fill('#email', LOGIN.email);
await page.fill('#password', LOGIN.password);
await page.click('[type=submit]');
await page.waitForURL('**/account');
// 2. Find the token twice: in localStorage, and in the header of the page's own API request.
const stored = await page.evaluate(() => localStorage.getItem('auth-token'));
let uiCalls = 0;
const countCall = (r) => { if (r.url().startsWith(`${API}/invoices`)) uiCalls++; };
page.on('request', countCall);
const [firstCall] = await Promise.all([
page.waitForRequest((r) => r.url().startsWith(`${API}/invoices`)),
page.goto(`${SITE}/account/invoices`),
]);
const header = (await firstCall.allHeaders()).authorization || '';
const claims = JSON.parse(Buffer.from(stored.split('.')[1], 'base64url').toString());
// 3. The page's way: click through the pager, reading the table each time.
await page.waitForSelector('table tbody tr');
const t0 = Date.now();
const viaUi = [];
const readTable = () => page.$$eval('table tbody tr td:first-child', (tds) => tds.map((td) => td.textContent.trim()));
viaUi.push(...await readTable());
const lastPage = Number(await page.locator('button[aria-label^="Page-"]').last().innerText());
for (let n = 2; n <= lastPage; n++) {
await Promise.all([
page.waitForResponse((r) => r.url() === `${API}/invoices?page=${n}`),
page.click('[data-test="pagination-next"]'),
]);
await page.waitForFunction((first) => document.querySelector('table tbody tr td')?.textContent.trim() !== first, viaUi[viaUi.length - 15]);
viaUi.push(...await readTable());
}
const uiSeconds = (Date.now() - t0) / 1000;
page.off('request', countCall);
const columns = await page.locator('table thead th').count();
// 4. The API's way: the same token on fetch() from inside the page — the browser makes the
// requests, through the session's proxy. Page 1 tells last_page; then all pages one by one,
// and all pages at once.
const viaApi = await page.evaluate(async ({ api, token }) => {
const get = (n) => fetch(`${api}/invoices?page=${n}`, { headers: { authorization: `Bearer ${token}` } }).then(async (r) => ({ status: r.status, text: await r.text() }));
const meta = JSON.parse((await get(1)).text);
const numbers = Array.from({ length: meta.last_page }, (_, i) => i + 1);
let t = performance.now();
const pages = [];
for (const n of numbers) pages.push(await get(n));
const sequential = (performance.now() - t) / 1000;
t = performance.now();
await Promise.all(numbers.map(get));
const parallel = (performance.now() - t) / 1000;
const noToken = await fetch(`${api}/invoices?page=1`).then((r) => r.status);
const items = pages.flatMap((p) => JSON.parse(p.text).data);
return {
sequential, parallel, total: meta.total, perPage: meta.per_page, lastPage: meta.last_page,
requests: pages.length, bytes: pages.reduce((s, p) => s + p.text.length, 0), statuses: [...new Set(pages.map((p) => p.status))],
invoices: items.map((i) => i.invoice_number), fields: Object.keys(items[0]).length, noToken,
};
}, { api: API, token: stored });
const sameSet = viaUi.length === viaApi.invoices.length && [...viaUi].sort().join() === [...viaApi.invoices].sort().join();
console.log(JSON.stringify({
token: {
in_local_storage: stored ? 'auth-token' : null,
header_matches: header === `Bearer ${stored}`,
lifetime_seconds: claims.exp - claims.iat,
seconds_left_at_end: claims.exp - Math.floor(Date.now() / 1000),
},
api: { total: viaApi.total, per_page: viaApi.perPage, last_page: viaApi.lastPage, without_token: viaApi.noToken },
ui: { pages: lastPage, invoices: viaUi.length, invoice_calls: uiCalls, columns, seconds: uiSeconds },
direct: { requests: viaApi.requests, statuses: viaApi.statuses, invoices: viaApi.invoices.length, fields_per_invoice: viaApi.fields, json_bytes: viaApi.bytes, seconds_one_by_one: viaApi.sequential, seconds_all_at_once: viaApi.parallel },
same_invoices: sameSet,
ui_missing_from_api: [...new Set(viaUi)].filter((n) => !viaApi.invoices.includes(n)).length,
}));
} finally {
await browser.close();
}
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import base64
import json
import os
import time
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
SITE = "https://practicesoftwaretesting.com" # Toolshop: a public demo shop built for test automation
API = "https://api.practicesoftwaretesting.com"
LOGIN = {"email": "[email protected]", "password": "welcome01"} # the site's published demo account
# Fetches every page of the API from inside the page with the token: one by one, then all at once.
FETCH_ALL = """async ({ api, token }) => {
const get = (n) => fetch(`${api}/invoices?page=${n}`, { headers: { authorization: `Bearer ${token}` } }).then(async (r) => ({ status: r.status, text: await r.text() }));
const meta = JSON.parse((await get(1)).text);
const numbers = Array.from({ length: meta.last_page }, (_, i) => i + 1);
let t = performance.now();
const pages = [];
for (const n of numbers) pages.push(await get(n));
const sequential = (performance.now() - t) / 1000;
t = performance.now();
await Promise.all(numbers.map(get));
const parallel = (performance.now() - t) / 1000;
const noToken = await fetch(`${api}/invoices?page=1`).then((r) => r.status);
const items = pages.flatMap((p) => JSON.parse(p.text).data);
return {
sequential, parallel, total: meta.total, perPage: meta.per_page, lastPage: meta.last_page,
requests: pages.length, bytes: pages.reduce((s, p) => s + p.text.length, 0), statuses: [...new Set(pages.map((p) => p.status))],
invoices: items.map((i) => i.invoice_number), fields: Object.keys(items[0]).length, noToken,
};
}"""
def launch(body):
# 503 means the fleet is momentarily busy: wait Retry-After and try again.
for attempt in range(1, 6):
res = requests.post("https://starter.cdpfleet.com/chromium/session", headers={"x-api-key": KEY}, json=body)
if res.ok:
return res.json()
if res.status_code != 503 or attempt == 5:
raise RuntimeError(f"launch {res.status_code} {res.text}")
time.sleep(int(res.headers.get("retry-after") or 5))
session = launch({"proxy": os.environ["PROXY_URL"], "headless": "new", "record": False})
with sync_playwright() as p:
browser = p.chromium.connect(session["wsUrl"], headers={"x-api-key": KEY})
try:
page = browser.new_page()
# The app can take 30 s to render through a rotating residential exit: wait up to 90 s.
page.set_default_timeout(90000)
# 1. Log in like a person; the site stores its token and sends it with every API call.
page.goto(f"{SITE}/auth/login")
page.fill("#email", LOGIN["email"])
page.fill("#password", LOGIN["password"])
page.click("[type=submit]")
page.wait_for_url("**/account")
# 2. Find the token twice: in localStorage, and in the header of the page's own API request.
stored = page.evaluate("() => localStorage.getItem('auth-token')")
ui_calls = 0
def count_call(r):
global ui_calls
if r.url.startswith(f"{API}/invoices"):
ui_calls += 1
page.on("request", count_call)
with page.expect_request(lambda r: r.url.startswith(f"{API}/invoices")) as first_call:
page.goto(f"{SITE}/account/invoices")
header = first_call.value.all_headers().get("authorization", "")
payload = stored.split(".")[1]
claims = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
# 3. The page's way: click through the pager, reading the table each time.
page.wait_for_selector("table tbody tr")
t0 = time.time()
read_table = lambda: page.eval_on_selector_all("table tbody tr td:first-child", "tds => tds.map((td) => td.textContent.trim())")
via_ui = read_table()
last_page = int(page.locator('button[aria-label^="Page-"]').last.inner_text())
for n in range(2, last_page + 1):
with page.expect_response(lambda r, n=n: r.url == f"{API}/invoices?page={n}"):
page.click('[data-test="pagination-next"]')
page.wait_for_function("first => document.querySelector('table tbody tr td')?.textContent.trim() !== first", arg=via_ui[-15])
via_ui += read_table()
ui_seconds = time.time() - t0
page.remove_listener("request", count_call)
columns = page.locator("table thead th").count()
# 4. The API's way: the same token on fetch() from inside the page — the browser makes the
# requests, through the session's proxy.
via_api = page.evaluate(FETCH_ALL, {"api": API, "token": stored})
print(json.dumps({
"token": {
"in_local_storage": "auth-token" if stored else None,
"header_matches": header == f"Bearer {stored}",
"lifetime_seconds": claims["exp"] - claims["iat"],
"seconds_left_at_end": claims["exp"] - int(time.time()),
},
"api": {"total": via_api["total"], "per_page": via_api["perPage"], "last_page": via_api["lastPage"], "without_token": via_api["noToken"]},
"ui": {"pages": last_page, "invoices": len(via_ui), "invoice_calls": ui_calls, "columns": columns, "seconds": ui_seconds},
"direct": {"requests": via_api["requests"], "statuses": via_api["statuses"], "invoices": len(via_api["invoices"]), "fields_per_invoice": via_api["fields"],
"json_bytes": via_api["bytes"], "seconds_one_by_one": via_api["sequential"], "seconds_all_at_once": via_api["parallel"]},
"same_invoices": len(via_ui) == len(via_api["invoices"]) and sorted(via_ui) == sorted(via_api["invoices"]),
"ui_missing_from_api": len(set(via_ui) - set(via_api["invoices"])),
}))
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.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final String SITE = "https://practicesoftwaretesting.com"; // Toolshop: a public demo shop built for test automation
static final String API = "https://api.practicesoftwaretesting.com";
static final String EMAIL = "[email protected]", PASSWORD = "welcome01"; // the site's published demo account
// Fetches every page of the API from inside the page with the token: one by one, then all at once.
static final String FETCH_ALL = String.join("\n",
"async ({ api, token }) => {",
" const get = (n) => fetch(`${api}/invoices?page=${n}`, { headers: { authorization: `Bearer ${token}` } }).then(async (r) => ({ status: r.status, text: await r.text() }));",
" const meta = JSON.parse((await get(1)).text);",
" const numbers = Array.from({ length: meta.last_page }, (_, i) => i + 1);",
" let t = performance.now();",
" const pages = [];",
" for (const n of numbers) pages.push(await get(n));",
" const sequential = (performance.now() - t) / 1000;",
" t = performance.now();",
" await Promise.all(numbers.map(get));",
" const parallel = (performance.now() - t) / 1000;",
" const noToken = await fetch(`${api}/invoices?page=1`).then((r) => r.status);",
" const items = pages.flatMap((p) => JSON.parse(p.text).data);",
" return JSON.stringify({",
" sequential, parallel, total: meta.total, perPage: meta.per_page, lastPage: meta.last_page,",
" requests: pages.length, bytes: pages.reduce((s, p) => s + p.text.length, 0), statuses: [...new Set(pages.map((p) => p.status))],",
" invoices: items.map((i) => i.invoice_number), fields: Object.keys(items[0]).length, noToken,",
" });",
"}");
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
static JsonObject launch(JsonObject body) throws Exception {
for (int attempt = 1; ; attempt++) {
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.toString())).build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() == 200) return JsonParser.parseString(res.body()).getAsJsonObject();
if (res.statusCode() != 503 || attempt == 5) throw new RuntimeException("launch " + res.statusCode() + " " + res.body());
Thread.sleep(Long.parseLong(res.headers().firstValue("retry-after").orElse("5")) * 1000);
}
}
@SuppressWarnings("unchecked")
static List<String> readTable(Page page) {
return (List<String>) page.evalOnSelectorAll("table tbody tr td:first-child", "tds => tds.map((td) => td.textContent.trim())");
}
public static void main(String[] args) throws Exception {
JsonObject body = new JsonObject();
body.addProperty("proxy", System.getenv("PROXY_URL"));
body.addProperty("headless", "new");
body.addProperty("record", false);
String wsUrl = launch(body).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();
// The app can take 30 s to render through a rotating residential exit: wait up to 90 s.
page.setDefaultTimeout(90000);
// 1. Log in like a person; the site stores its token and sends it with every API call.
page.navigate(SITE + "/auth/login");
page.fill("#email", EMAIL);
page.fill("#password", PASSWORD);
page.click("[type=submit]");
page.waitForURL("**/account");
// 2. Find the token twice: in localStorage, and in the header of the page's own API request.
String stored = (String) page.evaluate("() => localStorage.getItem('auth-token')");
AtomicInteger uiCalls = new AtomicInteger();
java.util.function.Consumer<Request> countCall = r -> { if (r.url().startsWith(API + "/invoices")) uiCalls.incrementAndGet(); };
page.onRequest(countCall);
Request firstCall = page.waitForRequest(r -> r.url().startsWith(API + "/invoices"), () -> page.navigate(SITE + "/account/invoices"));
String header = firstCall.allHeaders().getOrDefault("authorization", "");
JsonObject claims = JsonParser.parseString(new String(Base64.getUrlDecoder().decode(stored.split("\\.")[1]), StandardCharsets.UTF_8)).getAsJsonObject();
// 3. The page's way: click through the pager, reading the table each time.
page.waitForSelector("table tbody tr");
long t0 = System.nanoTime();
List<String> viaUi = new ArrayList<>(readTable(page));
int lastPage = Integer.parseInt(page.locator("button[aria-label^=\"Page-\"]").last().innerText().trim());
for (int n = 2; n <= lastPage; n++) {
String want = API + "/invoices?page=" + n;
page.waitForResponse(r -> r.url().equals(want), () -> page.click("[data-test=\"pagination-next\"]"));
page.waitForFunction("first => document.querySelector('table tbody tr td')?.textContent.trim() !== first", viaUi.get(viaUi.size() - 15));
viaUi.addAll(readTable(page));
}
double uiSeconds = (System.nanoTime() - t0) / 1e9;
page.offRequest(countCall);
int columns = page.locator("table thead th").count();
// 4. The API's way: the same token on fetch() from inside the page — the browser makes the
// requests, through the session's proxy.
JsonObject viaApi = JsonParser.parseString((String) page.evaluate(FETCH_ALL, Map.of("api", API, "token", stored))).getAsJsonObject();
List<String> apiInvoices = new ArrayList<>();
for (JsonElement e : viaApi.getAsJsonArray("invoices")) apiInvoices.add(e.getAsString());
List<String> a = new ArrayList<>(viaUi), b = new ArrayList<>(apiInvoices);
Collections.sort(a);
Collections.sort(b);
JsonObject token = new JsonObject();
token.addProperty("in_local_storage", stored != null ? "auth-token" : null);
token.addProperty("header_matches", header.equals("Bearer " + stored));
token.addProperty("lifetime_seconds", claims.get("exp").getAsLong() - claims.get("iat").getAsLong());
token.addProperty("seconds_left_at_end", claims.get("exp").getAsLong() - System.currentTimeMillis() / 1000);
JsonObject api = new JsonObject();
api.add("total", viaApi.get("total"));
api.add("per_page", viaApi.get("perPage"));
api.add("last_page", viaApi.get("lastPage"));
api.add("without_token", viaApi.get("noToken"));
JsonObject ui = new JsonObject();
ui.addProperty("pages", lastPage);
ui.addProperty("invoices", viaUi.size());
ui.addProperty("invoice_calls", uiCalls.get());
ui.addProperty("columns", columns);
ui.addProperty("seconds", uiSeconds);
JsonObject direct = new JsonObject();
direct.add("requests", viaApi.get("requests"));
direct.add("statuses", viaApi.get("statuses"));
direct.addProperty("invoices", apiInvoices.size());
direct.add("fields_per_invoice", viaApi.get("fields"));
direct.add("json_bytes", viaApi.get("bytes"));
direct.add("seconds_one_by_one", viaApi.get("sequential"));
direct.add("seconds_all_at_once", viaApi.get("parallel"));
JsonObject out = new JsonObject();
out.add("token", token);
out.add("api", api);
out.add("ui", ui);
out.add("direct", direct);
out.addProperty("same_invoices", a.equals(b));
Set<String> missing = new HashSet<>(viaUi);
missing.removeAll(apiInvoices);
out.addProperty("ui_missing_from_api", missing.size());
System.out.println(out);
} finally {
browser.close();
}
}
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
using System.Diagnostics;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Playwright;
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
const string Site = "https://practicesoftwaretesting.com"; // Toolshop: a public demo shop built for test automation
const string Api = "https://api.practicesoftwaretesting.com";
const string Email = "[email protected]", Password = "welcome01"; // the site's published demo account
// Fetches every page of the API from inside the page with the token: one by one, then all at once.
const string FetchAll = """
async ({ api, token }) => {
const get = (n) => fetch(`${api}/invoices?page=${n}`, { headers: { authorization: `Bearer ${token}` } }).then(async (r) => ({ status: r.status, text: await r.text() }));
const meta = JSON.parse((await get(1)).text);
const numbers = Array.from({ length: meta.last_page }, (_, i) => i + 1);
let t = performance.now();
const pages = [];
for (const n of numbers) pages.push(await get(n));
const sequential = (performance.now() - t) / 1000;
t = performance.now();
await Promise.all(numbers.map(get));
const parallel = (performance.now() - t) / 1000;
const noToken = await fetch(`${api}/invoices?page=1`).then((r) => r.status);
const items = pages.flatMap((p) => JSON.parse(p.text).data);
return JSON.stringify({
sequential, parallel, total: meta.total, perPage: meta.per_page, lastPage: meta.last_page,
requests: pages.length, bytes: pages.reduce((s, p) => s + p.text.length, 0), statuses: [...new Set(pages.map((p) => p.status))],
invoices: items.map((i) => i.invoice_number), fields: Object.keys(items[0]).length, noToken,
});
}
""";
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async Task<JsonElement> Launch(JsonObject body)
{
for (var attempt = 1; ; attempt++)
{
var res = await http.PostAsync("https://starter.cdpfleet.com/chromium/session",
new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"));
if (res.IsSuccessStatusCode) return await res.Content.ReadFromJsonAsync<JsonElement>();
if ((int)res.StatusCode != 503 || attempt == 5) throw new Exception($"launch {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
await Task.Delay((int)(res.Headers.RetryAfter?.Delta?.TotalMilliseconds ?? 5000));
}
}
var session = await Launch(new JsonObject { ["proxy"] = Environment.GetEnvironmentVariable("PROXY_URL"), ["headless"] = "new", ["record"] = false });
using var playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.ConnectAsync(session.GetProperty("wsUrl").GetString()!, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
try
{
var page = await browser.NewPageAsync();
// The app can take 30 s to render through a rotating residential exit: wait up to 90 s.
page.SetDefaultTimeout(90000);
// 1. Log in like a person; the site stores its token and sends it with every API call.
await page.GotoAsync($"{Site}/auth/login");
await page.FillAsync("#email", Email);
await page.FillAsync("#password", Password);
await page.ClickAsync("[type=submit]");
await page.WaitForURLAsync("**/account");
// 2. Find the token twice: in localStorage, and in the header of the page's own API request.
var stored = await page.EvaluateAsync<string>("() => localStorage.getItem('auth-token')");
var uiCalls = 0;
void CountCall(object? _, IRequest r) { if (r.Url.StartsWith($"{Api}/invoices")) uiCalls++; }
page.Request += CountCall;
var firstCall = await page.RunAndWaitForRequestAsync(() => page.GotoAsync($"{Site}/account/invoices"), r => r.Url.StartsWith($"{Api}/invoices"));
var header = (await firstCall.AllHeadersAsync()).GetValueOrDefault("authorization", "");
var payload = stored.Split('.')[1].Replace('-', '+').Replace('_', '/');
payload = payload.PadRight(payload.Length + (4 - payload.Length % 4) % 4, '=');
var claims = JsonDocument.Parse(Convert.FromBase64String(payload)).RootElement;
// 3. The page's way: click through the pager, reading the table each time.
await page.WaitForSelectorAsync("table tbody tr");
var clock = Stopwatch.StartNew();
async Task<string[]> ReadTable() => await page.EvalOnSelectorAllAsync<string[]>("table tbody tr td:first-child", "tds => tds.map((td) => td.textContent.trim())");
var viaUi = new List<string>(await ReadTable());
var lastPage = int.Parse((await page.Locator("button[aria-label^=\"Page-\"]").Last.InnerTextAsync()).Trim());
for (var n = 2; n <= lastPage; n++)
{
var want = $"{Api}/invoices?page={n}";
await page.RunAndWaitForResponseAsync(() => page.ClickAsync("[data-test=\"pagination-next\"]"), r => r.Url == want);
await page.WaitForFunctionAsync("first => document.querySelector('table tbody tr td')?.textContent.trim() !== first", viaUi[^15]);
viaUi.AddRange(await ReadTable());
}
var uiSeconds = clock.Elapsed.TotalSeconds;
page.Request -= CountCall;
var columns = await page.Locator("table thead th").CountAsync();
// 4. The API's way: the same token on fetch() from inside the page — the browser makes the
// requests, through the session's proxy.
var viaApi = JsonDocument.Parse(await page.EvaluateAsync<string>(FetchAll, new { api = Api, token = stored })).RootElement;
var apiInvoices = viaApi.GetProperty("invoices").EnumerateArray().Select((e) => e.GetString()!).ToList();
var output = new JsonObject
{
["token"] = new JsonObject
{
["in_local_storage"] = stored != null ? "auth-token" : null,
["header_matches"] = header == $"Bearer {stored}",
["lifetime_seconds"] = claims.GetProperty("exp").GetInt64() - claims.GetProperty("iat").GetInt64(),
["seconds_left_at_end"] = claims.GetProperty("exp").GetInt64() - DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
},
["api"] = new JsonObject
{
["total"] = viaApi.GetProperty("total").GetInt32(), ["per_page"] = viaApi.GetProperty("perPage").GetInt32(),
["last_page"] = viaApi.GetProperty("lastPage").GetInt32(), ["without_token"] = viaApi.GetProperty("noToken").GetInt32(),
},
["ui"] = new JsonObject { ["pages"] = lastPage, ["invoices"] = viaUi.Count, ["invoice_calls"] = uiCalls, ["columns"] = columns, ["seconds"] = uiSeconds },
["direct"] = new JsonObject
{
["requests"] = viaApi.GetProperty("requests").GetInt32(),
["statuses"] = JsonNode.Parse(viaApi.GetProperty("statuses").GetRawText()),
["invoices"] = apiInvoices.Count,
["fields_per_invoice"] = viaApi.GetProperty("fields").GetInt32(),
["json_bytes"] = viaApi.GetProperty("bytes").GetInt64(),
["seconds_one_by_one"] = viaApi.GetProperty("sequential").GetDouble(),
["seconds_all_at_once"] = viaApi.GetProperty("parallel").GetDouble(),
},
["same_invoices"] = viaUi.Count == apiInvoices.Count && viaUi.Order().SequenceEqual(apiInvoices.Order()),
["ui_missing_from_api"] = viaUi.Distinct().Except(apiInvoices).Count(),
};
Console.WriteLine(output.ToJsonString());
}
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/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"regexp"
"sort"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/playwright-community/playwright-go"
)
var key = os.Getenv("CDPFLEET_API_KEY")
const (
site = "https://practicesoftwaretesting.com" // Toolshop: a public demo shop built for test automation
api = "https://api.practicesoftwaretesting.com"
email = "[email protected]" // the site's published demo account
password = "welcome01"
)
// Fetches every page of the API from inside the page with the token: one by one, then all at once.
// (No JS template literals: Go raw strings can't hold backticks.)
const fetchAll = `async ({ api, token }) => {
const get = (n) => fetch(api + '/invoices?page=' + n, { headers: { authorization: 'Bearer ' + token } }).then(async (r) => ({ status: r.status, text: await r.text() }));
const meta = JSON.parse((await get(1)).text);
const numbers = Array.from({ length: meta.last_page }, (_, i) => i + 1);
let t = performance.now();
const pages = [];
for (const n of numbers) pages.push(await get(n));
const sequential = (performance.now() - t) / 1000;
t = performance.now();
await Promise.all(numbers.map(get));
const parallel = (performance.now() - t) / 1000;
const noToken = await fetch(api + '/invoices?page=1').then((r) => r.status);
const items = pages.flatMap((p) => JSON.parse(p.text).data);
return JSON.stringify({
sequential, parallel, total: meta.total, perPage: meta.per_page, lastPage: meta.last_page,
requests: pages.length, bytes: pages.reduce((s, p) => s + p.text.length, 0), statuses: [...new Set(pages.map((p) => p.status))],
invoices: items.map((i) => i.invoice_number), fields: Object.keys(items[0]).length, noToken,
});
}`
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
func launch(opts map[string]any) (map[string]any, error) {
body, _ := json.Marshal(opts)
for attempt := 1; ; attempt++ {
req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/chromium/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
}
b, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode == 200 {
var session map[string]any
return session, json.Unmarshal(b, &session)
}
if res.StatusCode != 503 || attempt == 5 {
return nil, fmt.Errorf("launch %d %s", res.StatusCode, strings.TrimSpace(string(b)))
}
wait, _ := strconv.Atoi(res.Header.Get("retry-after"))
if wait == 0 {
wait = 5
}
time.Sleep(time.Duration(wait) * time.Second)
}
}
func must(err error) {
if err != nil {
log.Fatal(err)
}
}
func readTable(page playwright.Page) []string {
cells, err := page.EvalOnSelectorAll("table tbody tr td:first-child", "tds => tds.map((td) => td.textContent.trim())")
must(err)
var out []string
for _, c := range cells.([]any) {
out = append(out, c.(string))
}
return out
}
func main() {
session, err := launch(map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": "new", "record": false})
must(err)
pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
must(err)
defer pw.Stop()
browser, err := pw.Chromium.Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
must(err)
defer browser.Close()
page, err := browser.NewPage()
must(err)
// The app can take 30 s to render through a rotating residential exit: wait up to 90 s.
page.SetDefaultTimeout(90000)
// 1. Log in like a person; the site stores its token and sends it with every API call.
_, err = page.Goto(site + "/auth/login")
must(err)
must(page.Fill("#email", email))
must(page.Fill("#password", password))
must(page.Click("[type=submit]"))
must(page.WaitForURL("**/account"))
// 2. Find the token twice: in localStorage, and in the header of the page's own API request.
storedAny, err := page.Evaluate("() => localStorage.getItem('auth-token')")
must(err)
stored, _ := storedAny.(string)
var uiCalls, counting int32 = 0, 1
page.OnRequest(func(r playwright.Request) {
if atomic.LoadInt32(&counting) == 1 && strings.HasPrefix(r.URL(), api+"/invoices") {
atomic.AddInt32(&uiCalls, 1)
}
})
firstCall, err := page.ExpectRequest(regexp.MustCompile(`^`+regexp.QuoteMeta(api+"/invoices")), func() error {
_, err := page.Goto(site + "/account/invoices")
return err
})
must(err)
headers, err := firstCall.AllHeaders()
must(err)
raw, err := base64.RawURLEncoding.DecodeString(strings.Split(stored, ".")[1])
must(err)
var claims struct {
Exp int64 `json:"exp"`
Iat int64 `json:"iat"`
}
must(json.Unmarshal(raw, &claims))
// 3. The page's way: click through the pager, reading the table each time.
_, err = page.WaitForSelector("table tbody tr")
must(err)
t0 := time.Now()
viaUi := readTable(page)
lastText, err := page.Locator(`button[aria-label^="Page-"]`).Last().InnerText()
must(err)
lastPage, _ := strconv.Atoi(strings.TrimSpace(lastText))
for n := 2; n <= lastPage; n++ {
_, err := page.ExpectResponse(fmt.Sprintf("%s/invoices?page=%d", api, n), func() error {
return page.Click(`[data-test="pagination-next"]`)
})
must(err)
_, err = page.WaitForFunction("first => document.querySelector('table tbody tr td')?.textContent.trim() !== first", viaUi[len(viaUi)-15])
must(err)
viaUi = append(viaUi, readTable(page)...)
}
uiSeconds := time.Since(t0).Seconds()
atomic.StoreInt32(&counting, 0)
columns, err := page.Locator("table thead th").Count()
must(err)
// 4. The API's way: the same token on fetch() from inside the page — the browser makes the
// requests, through the session's proxy.
res, err := page.Evaluate(fetchAll, map[string]any{"api": api, "token": stored})
must(err)
var viaApi struct {
Sequential float64 `json:"sequential"`
Parallel float64 `json:"parallel"`
Total int `json:"total"`
PerPage int `json:"perPage"`
LastPage int `json:"lastPage"`
Requests int `json:"requests"`
Bytes int `json:"bytes"`
Statuses []int `json:"statuses"`
Invoices []string `json:"invoices"`
Fields int `json:"fields"`
NoToken int `json:"noToken"`
}
must(json.Unmarshal([]byte(res.(string)), &viaApi))
a := append([]string{}, viaUi...)
b := append([]string{}, viaApi.Invoices...)
sort.Strings(a)
sort.Strings(b)
inApi := map[string]bool{}
for _, n := range viaApi.Invoices {
inApi[n] = true
}
seen := map[string]bool{}
missing := 0
for _, n := range viaUi {
if !inApi[n] && !seen[n] {
missing++
}
seen[n] = true
}
var inStorage any
if stored != "" {
inStorage = "auth-token"
}
out, _ := json.Marshal(map[string]any{
"token": map[string]any{
"in_local_storage": inStorage,
"header_matches": headers["authorization"] == "Bearer "+stored,
"lifetime_seconds": claims.Exp - claims.Iat,
"seconds_left_at_end": claims.Exp - time.Now().Unix(),
},
"api": map[string]any{"total": viaApi.Total, "per_page": viaApi.PerPage, "last_page": viaApi.LastPage, "without_token": viaApi.NoToken},
"ui": map[string]any{"pages": lastPage, "invoices": len(viaUi), "invoice_calls": atomic.LoadInt32(&uiCalls), "columns": columns, "seconds": uiSeconds},
"direct": map[string]any{
"requests": viaApi.Requests, "statuses": viaApi.Statuses, "invoices": len(viaApi.Invoices), "fields_per_invoice": viaApi.Fields,
"json_bytes": viaApi.Bytes, "seconds_one_by_one": viaApi.Sequential, "seconds_all_at_once": viaApi.Parallel,
},
"same_invoices": strings.Join(a, ",") == strings.Join(b, ","),
"ui_missing_from_api": missing,
})
fmt.Println(string(out))
}
What we got
| Way | Invoices | /invoices calls | Data per invoice | Time (s) |
|---|---|---|---|---|
| Click Next through the table | 162 | 11 | 5 columns | 5.5 |
| API with the token, one by one | 162 | 11 | 16 fields | 4.2 |
| API with the token, all at once | 162 | 11 | 16 fields | 0.8 |
| API without the token | 0 | 1 | HTTP 401 |
From the Node.js run on 2026-10-18. 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)
{
"token": {
"in_local_storage": "auth-token",
"header_matches": true,
"lifetime_seconds": 300,
"seconds_left_at_end": 287
},
"api": {
"total": 162,
"per_page": 15,
"last_page": 11,
"without_token": 401
},
"ui": {
"pages": 11,
"invoices": 162,
"invoice_calls": 11,
"columns": 5,
"seconds": 5.479
},
"direct": {
"requests": 11,
"statuses": [
200
],
"invoices": 162,
"fields_per_invoice": 16,
"json_bytes": 406666,
"seconds_one_by_one": 4.1995,
"seconds_all_at_once": 0.7525
},
"same_invoices": true,
"ui_missing_from_api": 0
}Takeaways
- The token is in the browser after login: the app stored a JWT under
auth-tokenin localStorage and sent exactly that asAuthorization: Bearer …on its own API calls. Without it the API answered 401. - Same records, more data: the API returned every invoice the table showed, about 160 of them. The page showed 5 columns; the API returned 16 fields per invoice, including the invoice lines and payment — about 400 KB of JSON.
- Page numbers over a list that changes: the demo account is shared with everyone practising on the site, and invoices were added while the programs ran: while building this case, one run counted 161 in the table and 162 from the API, another got 164 entries from 11 API pages for 163 invoices: a new invoice at the top pushed one onto the next page, so it was read twice. De-duplicate by id when you page by number, and expect to miss what moves the other way.
- The page size is the server's: 15 per page — 11 or 12 pages as the list grew — and
per_pageorlimitparameters were ignored, so the API needs as many requests as the clicks do: one/invoicescall per page either way. - The win is parallelism, not skipping the UI: fetched one by one, the 11 API pages took about as long as clicking Next through the pages — several seconds, each request a round trip through the residential exit — but all at once they took about a second. Without a token you could only click.
- Tokens expire: this one lived 300 seconds (
exp − iatin the JWT). Checkexpbefore a long job and log in again — or read the fresh token the page keeps — when it runs out. - Compare with infinite scroll, two ways, where the page's API needs no token, and fetch first, which skips the browser when the HTML already has the data.