Product alternatives
Read a product page, find comparable products, rank them by how close they are (with a reason each) and show the price difference. Prices are remembered, so the next run says which went up or down.
When to use it: You want comparable products to one you picked, ranked with a reason each and the price difference.
| Uses | extract (the product, then the candidates in one call), search when no candidates are given |
|---|---|
| Needs | a model on the API (no model key of your own); web search when CANDIDATES is empty |
| Site | a books.toscrape.com product (a demo shop) and four candidates; any product page of yours |
| Output | output/alternatives.md, output/result.json |
| Languages | Node and Python |
Candidates come from CANDIDATES (product pages you choose: your catalogue, a few shops you trust) or from the platform's web search. Each is scored from 0 (nothing alike) to 1 (the same kind of thing for the same buyer); pages that sell nothing are left out. The difference in price is given when both are in the same currency.
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.
/** * Product alternatives: read a product page, find comparable products (from a web search, or the candidate pages you * give), rank them by how close they are with a reason each, and show the price difference. Prices are remembered, * so the next run says which went up or down. * * npx tsx node/index.ts (BOXLINE_API_KEY; PRODUCT_URL; CANDIDATES comma-separated, else a search) * * Writes output/result.json and output/alternatives.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 productUrl = process.env.PRODUCT_URL ?? "https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html";const given = (process.env.CANDIDATES ?? "").split(",").map((u) => u.trim()).filter(Boolean);const bx = new Boxline();type Product = { url: string; name: string; price: number | null; currency: string | null; category: string | null; features: string[] };const PRODUCT = { type: "object", properties: { url: { type: "string" }, name: { type: "string" }, price: { type: ["number", "null"], description: "a number without the currency sign" }, currency: { type: ["string", "null"], description: "ISO 4217" }, category: { type: ["string", "null"] }, features: { type: "array", items: { type: "string" }, description: "what it is and does, in a few words each" }, }, required: ["url", "name", "price", "currency", "category", "features"],};// 1. The product itself.const base = await bx.extract<Product>({ url: productUrl, prompt: "The product on this page. url: the page's address as given.", schema: PRODUCT });let modelUsd = base.usage.costUsd;const product = { ...base.data, url: productUrl };console.log(`${product.name}: ${product.price ?? "?"} ${product.currency ?? ""} (${product.category ?? "no category"})`);// 2. Candidates: the pages given, or product pages a search finds (not this one).let searches = 0;let candidates = given;if (!candidates.length) { const found = await bx.search({ query: `${product.name} alternatives similar ${product.category ?? ""}`.trim(), limit: 10 }); searches = found.cached ? 0 : 1; candidates = found.results.map((r) => r.url).filter((u) => u !== productUrl).slice(0, 6);}const r = await bx.extract<{ items: (Product & { similarity: number; why: string })[] }>({ urls: candidates.slice(0, 10), prompt: `Each page may sell a product. For each page, the product on it, and how close it is to this one: "${product.name}" (${product.category ?? "?"}; ${product.features.join(", ")}). ` + `similarity: 0 (nothing alike) to 1 (the same kind of thing for the same buyer); why: one sentence. Pages that sell no product get similarity 0. url: the page's address as given.`, schema: { type: "object", properties: { items: { type: "array", items: { ...PRODUCT, properties: { ...PRODUCT.properties, similarity: { type: "number" }, why: { type: "string" } }, required: [...PRODUCT.required, "similarity", "why"] } } }, required: ["items"] },});modelUsd += r.usage.costUsd;// 3. Ranked, with the price difference, and the change since the last run.mkdirSync(stateDir, { recursive: true });const stateFile = join(stateDir, `${createHash("sha256").update(productUrl).digest("hex").slice(0, 16)}.json`);const before: Record<string, number | null> = existsSync(stateFile) ? JSON.parse(readFileSync(stateFile, "utf8")) : {};const ranked = r.data.items .map((it, i) => ({ ...it, url: candidates.find((c) => c === it.url) ?? candidates[i] ?? it.url })) .filter((it) => it.similarity > 0) .sort((a, b) => b.similarity - a.similarity) .map((it) => ({ ...it, priceDiff: it.price !== null && product.price !== null && it.currency === product.currency ? Math.round((it.price - product.price) * 100) / 100 : null, priceChange: before[it.url] !== undefined && before[it.url] !== it.price ? { from: before[it.url], to: it.price } : null, }));writeFileSync(stateFile, JSON.stringify(Object.fromEntries([[productUrl, product.price], ...ranked.map((x) => [x.url, x.price])])));for (const x of ranked) { const diff = x.priceDiff === null ? "" : x.priceDiff === 0 ? ", same price" : `, ${x.priceDiff > 0 ? "+" : ""}${x.priceDiff} ${x.currency}`; console.log(` ${x.similarity.toFixed(2)} ${x.name}: ${x.price ?? "?"} ${x.currency ?? ""}${diff}${x.priceChange ? ` (was ${x.priceChange.from})` : ""}\n ${x.why}`);}const md = [ `# Alternatives to ${product.name}`, "", `[${product.name}](${productUrl}): ${product.price ?? "?"} ${product.currency ?? ""}`, "", "| | Product | Price | Difference | Why |", "|---|---|---|---|---|", ...ranked.map((x) => `| ${x.similarity.toFixed(2)} | [${x.name}](${x.url}) | ${x.price ?? "?"} ${x.currency ?? ""} | ${x.priceDiff ?? ""} | ${x.why} |`), "",].join("\n");mkdirSync(out, { recursive: true });writeFileSync(join(out, "alternatives.md"), md);writeFileSync(join(out, "result.json"), JSON.stringify({ product, candidates, alternatives: ranked, usage: { modelUsd, searches } }, null, 2));"""Product alternatives: read a product page, find comparable products (from a web search, or the candidate pages yougive), rank them by how close they are with a reason each, and show the price difference. Prices are remembered,so the next run says which went up or down. python python/main.py (BOXLINE_API_KEY; PRODUCT_URL; CANDIDATES comma-separated, else a search)Writes output/result.json and output/alternatives.md."""import hashlibimport jsonimport osfrom pathlib import Pathfrom boxline import Boxlineout = Path(os.environ.get("OUTPUT_DIR", "output"))state_dir = Path(os.environ.get("STATE_DIR", str(out / "state")))product_url = os.environ.get("PRODUCT_URL", "https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html")given = [u.strip() for u in os.environ.get("CANDIDATES", "").split(",") if u.strip()]bx = Boxline()PRODUCT = { "type": "object", "properties": { "url": {"type": "string"}, "name": {"type": "string"}, "price": {"type": ["number", "null"], "description": "a number without the currency sign"}, "currency": {"type": ["string", "null"], "description": "ISO 4217"}, "category": {"type": ["string", "null"]}, "features": {"type": "array", "items": {"type": "string"}, "description": "what it is and does, in a few words each"}, }, "required": ["url", "name", "price", "currency", "category", "features"],}# 1. The product itself.base = bx.extract(url=product_url, prompt="The product on this page. url: the page's address as given.", schema=PRODUCT)model_usd = base["usage"]["costUsd"]product = {**base["data"], "url": product_url}print(f"{product['name']}: {product['price']} {product['currency'] or ''} ({product['category'] or 'no category'})")# 2. Candidates: the pages given, or product pages a search finds (not this one).searches, candidates = 0, givenif not candidates: found = bx.search(f"{product['name']} alternatives similar {product['category'] or ''}".strip(), limit=10) searches = 0 if found["cached"] else 1 candidates = [x["url"] for x in found["results"] if x["url"] != product_url][:6]item = {**PRODUCT, "properties": {**PRODUCT["properties"], "similarity": {"type": "number"}, "why": {"type": "string"}}, "required": [*PRODUCT["required"], "similarity", "why"]}r = bx.extract( urls=candidates[:10], prompt=( f'Each page may sell a product. For each page, the product on it, and how close it is to this one: "{product["name"]}" ({product["category"] or "?"}; {", ".join(product["features"])}). ' "similarity: 0 (nothing alike) to 1 (the same kind of thing for the same buyer); why: one sentence. Pages that sell no product get similarity 0. url: the page's address as given." ), schema={"type": "object", "properties": {"items": {"type": "array", "items": item}}, "required": ["items"]},)model_usd += r["usage"]["costUsd"]# 3. Ranked, with the price difference, and the change since the last run.state_dir.mkdir(parents=True, exist_ok=True)state_file = state_dir / f"{hashlib.sha256(product_url.encode()).hexdigest()[:16]}.json"before = json.loads(state_file.read_text()) if state_file.exists() else {}ranked = []for i, it in enumerate(r["data"]["items"]): url = it["url"] if it["url"] in candidates else (candidates[i] if i < len(candidates) else it["url"]) if it["similarity"] <= 0: continue diff = round(it["price"] - product["price"], 2) if it["price"] is not None and product["price"] is not None and it["currency"] == product["currency"] else None change = {"from": before[url], "to": it["price"]} if url in before and before[url] != it["price"] else None ranked.append({**it, "url": url, "priceDiff": diff, "priceChange": change})ranked.sort(key=lambda x: -x["similarity"])state_file.write_text(json.dumps({product_url: product["price"], **{x["url"]: x["price"] for x in ranked}}))for x in ranked: diff = "" if x["priceDiff"] is None else ", same price" if x["priceDiff"] == 0 else f", {'+' if x['priceDiff'] > 0 else ''}{x['priceDiff']} {x['currency']}" was = f" (was {x['priceChange']['from']})" if x["priceChange"] else "" print(f" {x['similarity']:.2f} {x['name']}: {x['price']} {x['currency'] or ''}{diff}{was}\n {x['why']}")md = [f"# Alternatives to {product['name']}", "", f"[{product['name']}]({product_url}): {product['price']} {product['currency'] or ''}", "", "| | Product | Price | Difference | Why |", "|---|---|---|---|---|"]md += [f"| {x['similarity']:.2f} | [{x['name']}]({x['url']}) | {x['price']} {x['currency'] or ''} | {'' if x['priceDiff'] is None else x['priceDiff']} | {x['why']} |" for x in ranked] + [""]out.mkdir(parents=True, exist_ok=True)(out / "alternatives.md").write_text("\n".join(md))(out / "result.json").write_text(json.dumps({"product": product, "candidates": candidates, "alternatives": ranked, "usage": {"modelUsd": model_usd, "searches": searches}}, indent=2, ensure_ascii=False))Inputs
Environment variables the example reads:
PRODUCT_URL,CANDIDATES,STATE_DIR(keep it between runs).
Run it
Get the examples with git clone https://github.com/Boxline-dev/examples.git and open this one: cd examples/product-alternatives. 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.
# Node 18 or newer, in the example's folderexport BOXLINE_API_KEY=… # your API keynpm install @boxline/sdk tsxnpx tsx node/index.ts# Python 3.9 or newer, in the example's folderexport BOXLINE_API_KEY=… # your API keypip install boxline-sdkpython python/main.pyThen 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 alternatives are ranked, each comes from a candidate page, and each price difference is right. For the demo: "A Light in the Attic" costs £51.77, the three poetry books are alternatives, and the travel book ranks last or is left out.
Source
github.com/Boxline-dev/examples/product-alternatives 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.