Docs
Dashboard
Examples / Chat and researchBeta

Watch competitor pages

Check a few pages now and then, tell real changes (a price, a plan, a feature, an announcement) from noise (a date, a counter, a rotating banner), and post the real ones to your chat.

When to use it: You want to hear about real changes on other sites (a price, a plan, a feature), not about a date or a counter.

Usesfetch (text, every check), extract with a schema (only when a page's text changed)
Needsa model on the API (no model key of your own); a Slack-style incoming webhook if you want alerts in chat
Siteany pages; the runner uses stand-in pages that change between two rounds
Outputoutput/result.json (every round, page by page), output/alerts.md, the last facts in STATE_DIR
LanguagesNode and Python

How it decides: every check fetches the page as text in a sandboxed browser, which is cheap and needs no model. When the text is the same as last time, nothing else happens. When it differs, a model reads the page into facts (prices, features, announcements, keeping last time's wording for what did not change) and the code compares those facts with the last ones. So the alert says exactly what changed ("price of Pro: $29 → $35", "feature added: SSO"), and a page whose only change is a timestamp counts as noise and sends nothing.

The code

The example as it is in its folder. Both versions do the same job and write the same output/, so one result check covers both.

/** * Watch competitor pages: check a few pages, tell real changes (a price, a plan, a feature, an announcement) from * noise (a date, a counter), and post the real ones to your chat webhook. * *   npx tsx node/index.ts            (BOXLINE_API_KEY; PAGES, a comma-separated list; SLACK_WEBHOOK_URL optional) * * Each check fetches the page as text (cheap). Only when the text changed does a model read the page into facts * (prices, features, announcements), and the code compares those facts with the last ones: what changed is exact, * and a page whose only change is a timestamp is "noise". The facts are kept in STATE_DIR between runs; RUNS=n and * INTERVAL_SECONDS repeat the check (run it from cron, or a Boxline task, for a real schedule). * Writes output/result.json and output/alerts.md. */import { createHash } from "node:crypto";import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";import { join } from "node:path";import { Boxline } from "@boxline/sdk";const out = process.env.OUTPUT_DIR ?? "output";const stateDir = process.env.STATE_DIR ?? join(out, "state");const pages = (process.env.PAGES ?? "https://nodejs.org/en/about/previous-releases,https://www.python.org/downloads/").split(",").map((u) => u.trim()).filter(Boolean);const runs = Number(process.env.RUNS ?? 1);const interval = Number(process.env.INTERVAL_SECONDS ?? 3600);const chatHook = process.env.SLACK_WEBHOOK_URL;const bx = new Boxline();type Facts = { title: string; prices: { item: string; price: string }[]; features: string[]; announcements: string[] };type State = { url: string; hash: string; facts: Facts; checkedAt: string };const FACTS = {  type: "object",  properties: {    title: { type: "string" },    prices: { type: "array", items: { type: "object", properties: { item: { type: "string", description: "a plan or product name" }, price: { type: "string", description: "as written, e.g. $29" } }, required: ["item", "price"] } },    features: { type: "array", items: { type: "string" }, description: "features or included items the page lists" },    announcements: { type: "array", items: { type: "string" }, description: "news, posts or release names, by their headline" },  },  required: ["title", "prices", "features", "announcements"],};const sha = (s: string) => createHash("sha256").update(s).digest("hex");const file = (url: string) => join(stateDir, `${sha(url).slice(0, 16)}.json`);const load = (url: string): State | null => (existsSync(file(url)) ? JSON.parse(readFileSync(file(url), "utf8")) : null);/** What changed between two sets of facts, in plain words (empty: nothing that matters). */function diff(a: Facts, b: Facts): string[] {  const changes: string[] = [];  const before = new Map(a.prices.map((p) => [p.item.toLowerCase(), p]));  const after = new Map(b.prices.map((p) => [p.item.toLowerCase(), p]));  for (const [k, p] of after) {    const old = before.get(k);    if (!old) changes.push(`new price: ${p.item} ${p.price}`);    else if (old.price !== p.price) changes.push(`price of ${p.item}: ${old.price} → ${p.price}`);  }  for (const [k, p] of before) if (!after.has(k)) changes.push(`price removed: ${p.item} (was ${p.price})`);  const list = (label: string, x: string[], y: string[]) => {    const xs = new Set(x.map((v) => v.toLowerCase()));    const ys = new Set(y.map((v) => v.toLowerCase()));    for (const v of y) if (!xs.has(v.toLowerCase())) changes.push(`${label} added: ${v}`);    for (const v of x) if (!ys.has(v.toLowerCase())) changes.push(`${label} removed: ${v}`);  };  list("feature", a.features, b.features);  list("announcement", a.announcements, b.announcements);  return changes;}mkdirSync(stateDir, { recursive: true });const rounds: { round: number; at: string; pages: { url: string; status: "baseline" | "same" | "noise" | "changed" | "failed"; changes: string[]; error?: string }[] }[] = [];let modelUsd = 0;let alertsSent = 0;const alerts: string[] = [];for (let round = 1; round <= runs; round++) {  const at = new Date().toISOString();  const seen: (typeof rounds)[number]["pages"] = [];  for (const url of pages) {    try {      const page = await bx.fetch(url, { format: "text" }); // a real browser in a sandbox; no model, no cost per token      const hash = sha(page.content);      const prev = load(url);      if (prev && prev.hash === hash) {        seen.push({ url, status: "same", changes: [] });        console.log(`  same      ${url}`);        continue;      }      // The text changed (or this is the first look): read the page into facts. The last facts go into the prompt      // so unchanged items keep the same wording, and the comparison below stays exact.      const hint = prev ? `\nLast time the facts were (keep this wording for items that did not change): ${JSON.stringify(prev.facts).slice(0, 3000)}` : "";      const r = await bx.extract<Facts>({ url, prompt: `The page's prices, features and announcements, as listed on it.${hint}`, schema: FACTS });      modelUsd += r.usage.costUsd;      const changes = prev ? diff(prev.facts, r.data) : [];      const status = !prev ? "baseline" : changes.length ? "changed" : "noise";      writeFileSync(file(url), JSON.stringify({ url, hash, facts: r.data, checkedAt: at } satisfies State, null, 2));      seen.push({ url, status, changes });      console.log(`  ${status.padEnd(9)} ${url}${changes.map((c) => `\n              ${c}`).join("")}`);      if (changes.length) alerts.push(`*${r.data.title}* (${url})\n${changes.map((c) => `• ${c}`).join("\n")}`);    } catch (err) {      seen.push({ url, status: "failed", changes: [], error: (err as Error).message });      console.log(`  failed    ${url}: ${(err as Error).message}`);    }  }  rounds.push({ round, at, pages: seen });  const fresh = alerts.splice(0);  if (fresh.length) {    const text = `Competitor changes (${at.slice(0, 16).replace("T", " ")} UTC)\n\n${fresh.join("\n\n")}`;    writeFileSync(join(out, "alerts.md"), `${existsSync(join(out, "alerts.md")) ? readFileSync(join(out, "alerts.md"), "utf8") + "\n\n" : ""}${text}`);    if (chatHook) {      const res = await fetch(chatHook, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text }) });      if (res.ok) alertsSent++;      console.log(res.ok ? "  posted the changes to the chat webhook" : `  the chat webhook answered ${res.status}`);    }  }  console.log(`Round ${round} done: ${seen.filter((p) => p.status === "changed").length} changed, ${seen.filter((p) => p.status === "noise").length} noise only.`);  if (round === 1 && seen.some((p) => p.status === "baseline")) console.log("Baseline saved.");  if (round < runs) await new Promise((r) => setTimeout(r, interval * 1000));}writeFileSync(join(out, "result.json"), JSON.stringify({ pages, rounds, alertsSent, usage: { modelUsd } }, null, 2));

Inputs

Environment variables the example reads:

  • PAGES: the pages to watch, separated by commas (default: the Node.js and Python release pages).
  • SLACK_WEBHOOK_URL: your chat's incoming webhook (Slack's {"text": …} format, which many chat tools accept).
  • STATE_DIR: where the last facts are kept (default output/state). Keep it between runs.
  • RUNS, INTERVAL_SECONDS: check again in the same process (default: once). For a real schedule, run it from cron or a CI schedule with a kept STATE_DIR.

Run it

Get the examples with git clone https://github.com/Boxline-dev/examples.git and open this one: cd examples/watch-competitor-pages. Put your API key in BOXLINE_API_KEY (and BOXLINE_API_URL for another API), then run either version from the example’s folder. Both write to output/ and stop their sessions when they finish.

The first run saves a baseline; later runs report what changed since.

# Node 18 or newer, in the example's folderexport BOXLINE_API_KEY=…   # your API keynpm install @boxline/sdk tsxnpx tsx node/index.ts

Then check the result, from the same folder: npx tsx check.ts output. It exits with 0 only when the result is right.

What its check proves

check.ts reads what the example wrote and passes only when the result is right, not merely when the example ran.

The first round saves a baseline for every page. On the runner's stand-in pages, in round 2: the pricing page is reported as changed with exactly "Pro $29 → $35" and "SSO added", the blog (whose only change is a "readers online" counter) is noise, and one alert with the new price reached the stand-in chat webhook.

Source

github.com/Boxline-dev/examples/watch-competitor-pages on GitHub, with its README.md, check.ts and .env.example. The examples are open source. See all examples.

These docs describe a beta API. Endpoints, fields and SDK methods may change before general availability; breaking changes will be listed in the changelog.