Cases / #50 · 2026-10-15 · Easy
A price watcher in one program: two snapshots minutes apart, diffed, billed only while reading
Take a snapshot of a catalogue price and a live market price, wait two minutes, take another and diff them field by field. Each snapshot is its own short session, so a 2-minute watch costs seconds of browser time — and an idle session would have ended anyway.
Run on production on 2026-10-15: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
Price and stock monitoring means reading the same pages again and again and reporting what changed. The tempting version keeps one browser open and re-reads the page every few minutes — which bills the whole time, and on cdpfleet ends after the 60-second idle timeout anyway. How do you structure a watcher so it pays only for the reading, and how do you tell a real change from noise?
What we used, and why
| What | Why |
|---|---|
| One session per snapshot | Launch, read every target, browser.close(). Between snapshots nothing runs and nothing is billed. |
page.evaluate(read) per target | Each target has its own small read function: the price and stock text of a product page, the amount from a JSON price API. |
| A field-by-field diff | Compare the two snapshots per field and add the percentage change for numeric prices. |
WAIT_SECONDS = 120 | Stands in for your scheduler's interval (cron, a queue) so the case can be verified in one run. |
How it works
- Snapshot 1: one short session reads a product page on books.toscrape.com and the Bitcoin spot price from Coinbase's public API.
- Wait two minutes with no session open.
- Snapshot 2 the same way, then diff the two and report what changed.
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 WAIT_SECONDS = 120; // between the two snapshots — in a real watcher, your scheduler's interval
// What to watch: a catalogue page (its price only changes when the shop changes it) and a live
// market price that moves by the second.
const TARGETS = [
{ name: 'Book price', url: 'https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html',
read: () => ({ price: document.querySelector('.product_main .price_color').textContent.trim(), stock: document.querySelector('.product_main .availability').textContent.trim().replace(/\s+/g, ' ') }) },
{ name: 'Bitcoin spot (USD)', url: 'https://api.coinbase.com/v2/prices/BTC-USD/spot',
read: () => ({ price: JSON.parse(document.body.innerText).data.amount }) },
];
// 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));
}
}
// One snapshot = one short session: launch, read every target, close. Nothing is billed while
// waiting, and an idle session would end after 60 s anyway.
async function snapshot() {
const t = Date.now();
const { wsUrl } = await launch({ proxy: process.env.PROXY_URL, headless: true, record: false });
const browser = await chromium.connect(wsUrl, { headers: { 'x-api-key': KEY } });
try {
const page = await browser.newPage();
const values = {};
for (const target of TARGETS) {
for (let attempt = 1; ; attempt++) { // proxies drop a tunnel now and then
try {
await page.goto(target.url, { timeout: 45000 });
values[target.name] = await page.evaluate(target.read);
break;
} catch (err) {
if (attempt === 3) throw err;
}
}
}
return { at: new Date().toISOString(), seconds: Math.round((Date.now() - t) / 100) / 10, values };
} finally {
await browser.close();
}
}
// Field-by-field diff of two snapshots, with the percentage for numeric prices.
function diff(a, b) {
return TARGETS.map(({ name }) => {
const changes = Object.keys(a.values[name]).filter((k) => a.values[name][k] !== b.values[name][k]).map((k) => {
const [x, y] = [a.values[name][k], b.values[name][k]];
const [nx, ny] = [x, y].map((v) => Number(String(v).replace(/[^0-9.]/g, '')));
return { field: k, before: x, after: y, ...(nx && ny ? { percent: Math.round(((ny - nx) / nx) * 1e4) / 100 } : {}) };
});
return { target: name, changed: changes.length > 0, changes, now: b.values[name] };
});
}
const first = await snapshot();
await new Promise((r) => setTimeout(r, WAIT_SECONDS * 1000));
const second = await snapshot();
console.log(JSON.stringify({
wait_seconds: WAIT_SECONDS,
billed_seconds: Math.round((first.seconds + second.seconds) * 10) / 10,
first_at: first.at, second_at: second.at,
diff: diff(first, second),
}, null, 2));
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import json
import os
import re
import time
from datetime import datetime, timezone
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
WAIT_SECONDS = 120 # between the two snapshots — in a real watcher, your scheduler's interval
# What to watch: a catalogue page (its price only changes when the shop changes it) and a live
# market price that moves by the second.
TARGETS = [
{"name": "Book price", "url": "https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html",
"read": """() => ({ price: document.querySelector('.product_main .price_color').textContent.trim(), stock: document.querySelector('.product_main .availability').textContent.trim().replace(/\\s+/g, ' ') })"""},
{"name": "Bitcoin spot (USD)", "url": "https://api.coinbase.com/v2/prices/BTC-USD/spot",
"read": "() => ({ price: JSON.parse(document.body.innerText).data.amount })"},
]
# 503 means the fleet is momentarily busy: wait Retry-After and try again.
def launch(body):
for attempt in range(1, 6):
r = requests.post("https://starter.cdpfleet.com/chromium/session", headers={"x-api-key": KEY}, json=body, timeout=120)
if r.ok:
return r.json()
if r.status_code != 503 or attempt == 5:
raise RuntimeError(f"launch {r.status_code} {r.text}")
time.sleep(int(r.headers.get("retry-after") or 5))
# One snapshot = one short session: launch, read every target, close. Nothing is billed while
# waiting, and an idle session would end after 60 s anyway.
def snapshot(pw):
t = time.time()
s = launch({"proxy": os.environ["PROXY_URL"], "headless": True, "record": False})
browser = pw.chromium.connect(s["wsUrl"], headers={"x-api-key": KEY})
try:
page = browser.new_page()
values = {}
for target in TARGETS:
for attempt in range(1, 4): # proxies drop a tunnel now and then
try:
page.goto(target["url"], timeout=45000)
values[target["name"]] = page.evaluate(target["read"])
break
except Exception:
if attempt == 3:
raise
return {"at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), "seconds": round(time.time() - t, 1), "values": values}
finally:
browser.close()
def number(v):
try:
return float(re.sub(r"[^0-9.]", "", str(v)))
except ValueError:
return 0
# Field-by-field diff of two snapshots, with the percentage for numeric prices.
def diff(a, b):
out = []
for target in TARGETS:
name = target["name"]
changes = []
for k in a["values"][name]:
x, y = a["values"][name][k], b["values"][name][k]
if x != y:
c = {"field": k, "before": x, "after": y}
nx, ny = number(x), number(y)
if nx and ny:
c["percent"] = round((ny - nx) / nx * 100, 2)
changes.append(c)
out.append({"target": name, "changed": bool(changes), "changes": changes, "now": b["values"][name]})
return out
with sync_playwright() as pw:
first = snapshot(pw)
time.sleep(WAIT_SECONDS)
second = snapshot(pw)
print(json.dumps({
"wait_seconds": WAIT_SECONDS,
"billed_seconds": round(first["seconds"] + second["seconds"], 1),
"first_at": first["at"], "second_at": second["at"],
"diff": diff(first, second),
}, indent=2, ensure_ascii=False))
// 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.time.Instant;
import java.util.*;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
static final int WAIT_SECONDS = 120; // between the two snapshots — in a real watcher, your scheduler's interval
// What to watch: a catalogue page (its price only changes when the shop changes it) and a live
// market price that moves by the second.
record Target(String name, String url, String read) {}
static final Target[] TARGETS = {
new Target("Book price", "https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html",
"() => ({ price: document.querySelector('.product_main .price_color').textContent.trim(), stock: document.querySelector('.product_main .availability').textContent.trim().replace(/\\s+/g, ' ') })"),
new Target("Bitcoin spot (USD)", "https://api.coinbase.com/v2/prices/BTC-USD/spot", "() => ({ price: JSON.parse(document.body.innerText).data.amount })"),
};
// 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);
}
}
// One snapshot = one short session: launch, read every target, close. Nothing is billed while
// waiting, and an idle session would end after 60 s anyway.
static JsonObject snapshot(Playwright pw) throws Exception {
long t = System.currentTimeMillis();
JsonObject body = new JsonObject();
body.addProperty("proxy", System.getenv("PROXY_URL"));
body.addProperty("headless", true);
body.addProperty("record", false);
JsonObject s = launch(body);
Browser browser = pw.chromium().connect(s.get("wsUrl").getAsString(), new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
try {
Page page = browser.newPage();
JsonObject values = new JsonObject();
for (Target target : TARGETS) {
for (int attempt = 1; ; attempt++) { // proxies drop a tunnel now and then
try {
page.navigate(target.url(), new Page.NavigateOptions().setTimeout(45000));
values.add(target.name(), GSON.toJsonTree(page.evaluate(target.read())));
break;
} catch (PlaywrightException err) {
if (attempt == 3) throw err;
}
}
}
JsonObject out = new JsonObject();
out.addProperty("at", Instant.now().toString());
out.addProperty("seconds", Math.round((System.currentTimeMillis() - t) / 100.0) / 10.0);
out.add("values", values);
return out;
} finally {
browser.close();
}
}
static double number(String v) {
String digits = v.replaceAll("[^0-9.]", "");
try { return digits.isEmpty() ? 0 : Double.parseDouble(digits); } catch (NumberFormatException e) { return 0; }
}
// Field-by-field diff of two snapshots, with the percentage for numeric prices.
static JsonArray diff(JsonObject a, JsonObject b) {
JsonArray out = new JsonArray();
for (Target target : TARGETS) {
JsonObject va = a.getAsJsonObject("values").getAsJsonObject(target.name());
JsonObject vb = b.getAsJsonObject("values").getAsJsonObject(target.name());
JsonArray changes = new JsonArray();
for (String k : va.keySet()) {
String x = va.get(k).getAsString(), y = vb.get(k).getAsString();
if (x.equals(y)) continue;
JsonObject c = new JsonObject();
c.addProperty("field", k);
c.addProperty("before", x);
c.addProperty("after", y);
double nx = number(x), ny = number(y);
if (nx != 0 && ny != 0) c.addProperty("percent", Math.round((ny - nx) / nx * 1e4) / 100.0);
changes.add(c);
}
JsonObject d = new JsonObject();
d.addProperty("target", target.name());
d.addProperty("changed", changes.size() > 0);
d.add("changes", changes);
d.add("now", vb);
out.add(d);
}
return out;
}
public static void main(String[] args) throws Exception {
try (Playwright pw = Playwright.create()) {
JsonObject first = snapshot(pw);
Thread.sleep(WAIT_SECONDS * 1000L);
JsonObject second = snapshot(pw);
JsonObject out = new JsonObject();
out.addProperty("wait_seconds", WAIT_SECONDS);
out.addProperty("billed_seconds", Math.round((first.get("seconds").getAsDouble() + second.get("seconds").getAsDouble()) * 10) / 10.0);
out.add("first_at", first.get("at"));
out.add("second_at", second.get("at"));
out.add("diff", diff(first, second));
System.out.println(GSON.toJson(out));
}
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
using System.Net.Http.Json;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Playwright;
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
const int WaitSeconds = 120; // between the two snapshots — in a real watcher, your scheduler's interval
// What to watch: a catalogue page (its price only changes when the shop changes it) and a live
// market price that moves by the second.
var targets = new (string Name, string Url, string Read)[]
{
("Book price", "https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html",
@"() => ({ price: document.querySelector('.product_main .price_color').textContent.trim(), stock: document.querySelector('.product_main .availability').textContent.trim().replace(/\s+/g, ' ') })"),
("Bitcoin spot (USD)", "https://api.coinbase.com/v2/prices/BTC-USD/spot", "() => ({ price: JSON.parse(document.body.innerText).data.amount })"),
};
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();
// 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));
}
}
// One snapshot = one short session: launch, read every target, close. Nothing is billed while
// waiting, and an idle session would end after 60 s anyway.
async Task<(string At, double Seconds, Dictionary<string, JsonObject> Values)> Snapshot()
{
var t = DateTime.UtcNow;
var s = await Launch(new JsonObject { ["proxy"] = Environment.GetEnvironmentVariable("PROXY_URL"), ["headless"] = true, ["record"] = false });
var browser = await playwright.Chromium.ConnectAsync(s.GetProperty("wsUrl").GetString()!, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
try
{
var page = await browser.NewPageAsync();
var values = new Dictionary<string, JsonObject>();
foreach (var target in targets)
{
for (var attempt = 1; ; attempt++) // proxies drop a tunnel now and then
{
try
{
await page.GotoAsync(target.Url, new() { Timeout = 45000 });
values[target.Name] = JsonNode.Parse((await page.EvaluateAsync<JsonElement>(target.Read)).GetRawText())!.AsObject();
break;
}
catch (PlaywrightException) when (attempt < 3) { }
}
}
return (DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"), Math.Round((DateTime.UtcNow - t).TotalSeconds, 1), values);
}
finally
{
await browser.CloseAsync();
}
}
double Number(string v) => double.TryParse(Regex.Replace(v, "[^0-9.]", ""), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var n) ? n : 0;
var first = await Snapshot();
await Task.Delay(WaitSeconds * 1000);
var second = await Snapshot();
// Field-by-field diff of two snapshots, with the percentage for numeric prices.
var diff = new JsonArray();
foreach (var target in targets)
{
var changes = new JsonArray();
foreach (var (k, v) in first.Values[target.Name])
{
string x = v!.GetValue<string>(), y = second.Values[target.Name][k]!.GetValue<string>();
if (x == y) continue;
var c = new JsonObject { ["field"] = k, ["before"] = x, ["after"] = y };
double nx = Number(x), ny = Number(y);
if (nx != 0 && ny != 0) c["percent"] = Math.Round((ny - nx) / nx * 100, 2);
changes.Add(c);
}
diff.Add(new JsonObject { ["target"] = target.Name, ["changed"] = changes.Count > 0, ["changes"] = changes, ["now"] = second.Values[target.Name].DeepClone() });
}
Console.WriteLine(new JsonObject
{
["wait_seconds"] = WaitSeconds,
["billed_seconds"] = Math.Round(first.Seconds + second.Seconds, 1),
["first_at"] = first.At,
["second_at"] = second.At,
["diff"] = diff,
}.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
// 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"
"regexp"
"strconv"
"strings"
"time"
"github.com/playwright-community/playwright-go"
)
var key = os.Getenv("CDPFLEET_API_KEY")
const waitSeconds = 120 // between the two snapshots — in a real watcher, your scheduler's interval
// What to watch: a catalogue page (its price only changes when the shop changes it) and a live
// market price that moves by the second.
type target struct{ name, url, read string }
var targets = []target{
{"Book price", "https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html",
`() => ({ price: document.querySelector('.product_main .price_color').textContent.trim(), stock: document.querySelector('.product_main .availability').textContent.trim().replace(/\s+/g, ' ') })`},
{"Bitcoin spot (USD)", "https://api.coinbase.com/v2/prices/BTC-USD/spot", "() => ({ price: JSON.parse(document.body.innerText).data.amount })"},
}
// 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, err := strconv.Atoi(res.Header.Get("retry-after"))
if err != nil {
wait = 5
}
time.Sleep(time.Duration(wait) * time.Second)
}
}
type snap struct {
at string
seconds float64
values map[string]map[string]string
}
// One snapshot = one short session: launch, read every target, close. Nothing is billed while
// waiting, and an idle session would end after 60 s anyway.
func snapshot(pw *playwright.Playwright) (snap, error) {
t := time.Now()
out := snap{values: map[string]map[string]string{}}
s, err := launch(map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": true, "record": false})
if err != nil {
return out, err
}
browser, err := pw.Chromium.Connect(s["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
if err != nil {
return out, err
}
defer browser.Close()
page, err := browser.NewPage()
if err != nil {
return out, err
}
for _, tg := range targets {
for attempt := 1; ; attempt++ { // proxies drop a tunnel now and then
var raw any
_, err = page.Goto(tg.url, playwright.PageGotoOptions{Timeout: playwright.Float(45000)})
if err == nil {
raw, err = page.Evaluate(tg.read)
}
if err == nil {
vals := map[string]string{}
for k, v := range raw.(map[string]any) {
vals[k] = fmt.Sprint(v)
}
out.values[tg.name] = vals
break
}
if attempt == 3 {
return out, err
}
}
}
out.at = time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
out.seconds = math.Round(time.Since(t).Seconds()*10) / 10
return out, nil
}
var nonNumeric = regexp.MustCompile(`[^0-9.]`)
func number(v string) float64 {
n, _ := strconv.ParseFloat(nonNumeric.ReplaceAllString(v, ""), 64)
return n
}
type change struct {
Field string `json:"field"`
Before string `json:"before"`
After string `json:"after"`
Percent *float64 `json:"percent,omitempty"`
}
type targetDiff struct {
Target string `json:"target"`
Changed bool `json:"changed"`
Changes []change `json:"changes"`
Now map[string]string `json:"now"`
}
// Field-by-field diff of two snapshots, with the percentage for numeric prices.
func diff(a, b snap) []targetDiff {
out := []targetDiff{}
for _, tg := range targets {
changes := []change{}
for k, x := range a.values[tg.name] {
y := b.values[tg.name][k]
if x == y {
continue
}
c := change{Field: k, Before: x, After: y}
if nx, ny := number(x), number(y); nx != 0 && ny != 0 {
p := math.Round((ny-nx)/nx*1e4) / 100
c.Percent = &p
}
changes = append(changes, c)
}
out = append(out, targetDiff{tg.name, len(changes) > 0, changes, b.values[tg.name]})
}
return out
}
func run() error {
pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
if err != nil {
return err
}
defer pw.Stop()
first, err := snapshot(pw)
if err != nil {
return err
}
time.Sleep(waitSeconds * time.Second)
second, err := snapshot(pw)
if err != nil {
return err
}
// Field order matches the other languages' output.
out := struct {
WaitSeconds int `json:"wait_seconds"`
BilledSeconds float64 `json:"billed_seconds"`
FirstAt string `json:"first_at"`
SecondAt string `json:"second_at"`
Diff []targetDiff `json:"diff"`
}{waitSeconds, math.Round((first.seconds+second.seconds)*10) / 10, first.at, second.at, diff(first, second)}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(out); err != nil {
return err
}
fmt.Print(buf.String())
return nil
}
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
What we got
| Target | Changed? | Before | After |
|---|---|---|---|
| Book price | no | price: £51.77; stock: In stock (22 available) | same |
| Bitcoin spot (USD) | yes (+0.05%) | price: 81676.54 | price: 81715.885 |
From the Node.js run on 2026-10-15. 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)
{
"wait_seconds": 120,
"billed_seconds": 12.9,
"first_at": "2026-10-15T01:39:52.935Z",
"second_at": "2026-10-15T01:41:58.330Z",
"diff": [
{
"target": "Book price",
"changed": false,
"changes": [],
"now": {
"price": "£51.77",
"stock": "In stock (22 available)"
}
},
{
"target": "Bitcoin spot (USD)",
"changed": true,
"changes": [
{
"field": "price",
"before": "81676.54",
"after": "81715.885",
"percent": 0.05
}
],
"now": {
"price": "81715.885"
}
}
]
}Takeaways
- Pay for the reading, not the waiting: two short sessions billed about 15 seconds of browser time for a watch that took more than two minutes. Keeping one browser open would bill every second — and with no traffic it would end after the 60-second
inactivity_timeout. - Static and live targets behave as expected: the book's price and stock text were identical in both snapshots; in our run the market price moved between them, and the diff reports the before and after values with the percentage.
- Diff fields, not pages: comparing whole pages flags every rotating ad and timestamp. Read the fields you care about, compare those, and alert only on them.
- In production, store the last snapshot (a file or a database row) and run the same program from your scheduler; the diff logic doesn't change. For many URLs, read them in one session per snapshot as here — or in parallel sessions within your threads.