[Examples & benchmarks](https://cdpfleet.com/docs/cases) / #50 · 2026-10-15 · Example · Easy · for [monitoring](https://cdpfleet.com/monitoring), [web scraping](https://cdpfleet.com/web-scraping)

# 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.

[Chromium](https://cdpfleet.com/docs/browsers/chromium)

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

1.  Snapshot 1: one short session reads a product page on books.toscrape.com and the Bitcoin spot price from Coinbase's public API.
2.  Wait two minutes with no session open.
3.  Snapshot 2 the same way, then diff the two and report what changed.

## The code

The same program in five languages (also [on GitHub](https://github.com/cdpfleet/cdpfleet/tree/main/cases/50-price-watch), 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:pass@proxy.example.com:8000`

 

```node
// npm install playwright@1.60.0
// 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 2 minutes (the default) 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));
```

```python
# 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 2 minutes (the default) 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))
```

```java
// 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 2 minutes (the default) 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));
    }
  }
}
```

```csharp
// 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 2 minutes (the default) 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
// go get github.com/playwright-community/playwright-go@v0.6000.0
// 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 2 minutes (the default) 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.

---
Source: https://cdpfleet.com/docs/cases/price-watch · Markdown: https://cdpfleet.com/docs/cases/price-watch.md · All docs for AI tools: https://cdpfleet.com/llms.txt
