Dark pattern review
Two parts:
When to use it: You want an outside look at how clear a checkout flow is, on your own shop or a test shop.
| Uses | agent.run with sessionId, agent.stream, session.goto, session.evaluate (scan.js), session.extract with a schema |
|---|---|
| Needs | a model on the API |
| Site | saucedemo.com, a demo shop made for testing (it shows its demo sign-in on the page); the runner also scans its stand-in deals page and checkout, which have six known patterns |
| Output | output/review.md (the agent's review), output/patterns.md (the scan) and output/result.json (pages, measurements, patterns) |
| Languages | Node and Python |
- An agent goes through a checkout flow up to the last step before anything is bought, and points out confusing or deceptive steps (hidden costs, pre-ticked options, fake urgency, a hard way back).
- A page scan of each page the agent went through (or the pages you give):
- the browser measures what is there: boxes ticked in advance, countdowns that start again when the page is reloaded, fees that no earlier page showed, small print about money or renewals, and the wording of a pop-up's choices;
- a model sorts what it finds into categories: urgency, scarcity, sneaking, hidden costs, confirmshaming, forced continuity, obstruction, misdirection, forced action;
- every pattern quotes the page, and a quote that is not on the page is set aside, not reported.
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.
/** * Dark pattern review, in two parts: * 1. An agent goes through a sign-up or checkout flow, stops before anything is bought or sent, and points out * confusing or deceptive steps. The default is saucedemo.com, a demo shop made for testing. * 2. A page scan: each page the agent went through (or PAGES) is measured in the browser (boxes ticked in advance, * countdowns that start again on reload, fees no earlier page showed, small print, pop-up wording), and a model sorts what it finds * into categories. Every pattern quotes the page; a quote that is not on the page is set aside, not reported. * * npx tsx node/index.ts (BOXLINE_API_KEY; TASK to review your own flow; PAGES, comma-separated, to scan) * * Writes output/review.md, output/patterns.md and output/result.json. */import { mkdirSync, readFileSync, writeFileSync } from "node:fs";import { dirname, join } from "node:path";import { fileURLToPath } from "node:url";import { Boxline } from "@boxline/sdk";const out = process.env.OUTPUT_DIR ?? "output";const task = process.env.TASK ?? "Open https://www.saucedemo.com, a demo shop made for testing, and sign in with the demo username and password shown on " + "its page. Add one product to the cart and go through the checkout up to the last step before the order is placed, " + "using the name Test User and the postcode 10115. Don't place the order. For each step, tell me if anything is " + "confusing or deceptive: hidden costs, pre-ticked options, fake urgency, or a hard way back.";const pagesToScan = (process.env.PAGES ?? "").split(",").map((s) => s.trim()).filter(Boolean);const scanJs = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../scan.js"), "utf8");const bx = new Boxline();const CATEGORIES = ["urgency", "scarcity", "sneaking", "hidden_costs", "confirmshaming", "forced_continuity", "obstruction", "misdirection", "forced_action"];const PATTERNS = { type: "object", properties: { patterns: { type: "array", items: { type: "object", properties: { category: { type: "string", enum: CATEGORIES, description: "urgency: time pressure (countdowns, 'ends tonight'); scarcity: low-stock or high-demand claims; sneaking: something added or ticked for the visitor in advance; hidden_costs: fees shown late or only in the total; confirmshaming: a 'no' worded to shame; forced_continuity: a trial or plan that renews and charges unless cancelled; obstruction: hard to cancel, leave or say no; misdirection: misleading buttons or design that hides the real choice; forced_action: a sign-up, share or other unrelated step required to go on", }, name: { type: "string", description: "a few words, e.g. 'countdown that restarts'" }, quote: { type: "string", description: "the exact words on the page that show it, copied as written" }, why: { type: "string", description: "one sentence: why it misleads or pressures" }, }, required: ["category", "name", "quote", "why"], }, }, }, required: ["patterns"],};interface Pattern { category: string; name: string; quote: string; why: string;}interface Measures { url: string; title: string; text: string; preChecked: { label: string }[]; timers: { id: string | null; text: string; context: string; restartsOnReload?: boolean }[]; smallPrint: { text: string; fontSize: number }[]; dialogs: { text: string; choices: string[] }[]; charges: string[]; prices: string[]; lateCharges?: string[];}const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9$€£.%]+/g, " ").trim();const seconds = (clock: string) => clock.split(":").reduce((a, n) => a * 60 + Number(n), 0);const amounts = (s: string) => (s.match(/\d[\d,]*\.\d{2}/g) ?? []).map((a) => a.replace(/,/g, ""));const session = await bx.sessions.create({ timeout: 1200, userMetadata: { example: "dark-pattern-review" } });console.log(`Session: ${session.id}`);try { // 1. The agent's walk through the flow, in this session (so the scan sees the same signed-in pages). const run = await bx.agent.run({ task, sessionId: session.id, maxSteps: 40 }); console.log(`Agent run ${run.id} (${run.provider}/${run.model})`); for await (const e of bx.agent.stream(run.id)) { if (e.type === "tool") console.log(`→ ${e.name} ${JSON.stringify(e.input ?? {}).slice(0, 90)}`); else if (e.type === "done") console.log(`\n${e.status}:\n${e.result ?? e.error}`); } const done = await bx.agent.get(run.id); let modelUsd = done.usage.costUsd ?? 0; // Every page the agent was on, from its tool calls and what they reported back. const steps = JSON.stringify(done.steps.map((s) => [s.input, s.output])); const pages = [...new Set((steps.match(/https?:\/\/[^\s"'\\)]+/g) ?? []).map((u) => u.replace(/[.,]+$/, "")))]; // 2. The page scan: the pages given, else the agent's pages (never the one after an order is placed). const host = pages.length ? new URL(pages[0]!).host : ""; const targets = pagesToScan.length ? pagesToScan : pages.filter((u) => new URL(u).host === host && !/complete|confirm|thank/i.test(u)).slice(-4); const scans = []; const seenAmounts = new Set<string>(); // every amount on the pages scanned before this one for (const url of targets) { await session.goto(url); const t0 = Date.now(); const first = await session.evaluate<Measures>(scanJs); if (first.timers.length) { // A real countdown keeps going while we wait; a fake one starts again when the page is loaded again. await new Promise((r) => setTimeout(r, 3000)); await session.goto(url); const elapsed = (Date.now() - t0) / 1000; const again = await session.evaluate<Measures>(scanJs); first.timers.forEach((t, i) => { const later = again.timers[i]; if (later && /\d:\d\d/.test(t.text) && /\d:\d\d/.test(later.text)) t.restartsOnReload = seconds(t.text) - seconds(later.text) < elapsed - 2; }); } // A fee whose amount no earlier page showed was added late (drip pricing). if (scans.length) first.lateCharges = first.charges.filter((c) => amounts(c).some((a) => !seenAmounts.has(a))); for (const a of amounts(first.text)) seenAmounts.add(a); const { text: pageText, ...facts } = first; const r = await session.extract<{ patterns: Pattern[] }>( "Find the dark patterns on this page: design that pushes or tricks a visitor (false urgency or scarcity, options ticked in advance, " + "costs shown late, guilt-tripping wording on a 'no', renewals in small print, a hard way out, misleading buttons, forced sign-ups). " + "Report only what the page clearly shows, each with the exact words from the page. These measurements were taken in the browser (lateCharges: fees that no earlier page of the flow showed): " + JSON.stringify(facts).slice(0, 4000), { schema: PATTERNS }, ); modelUsd += r.usage.costUsd ?? 0; const page = norm(pageText); const verified = r.data.patterns.filter((p) => p.quote && page.includes(norm(p.quote))); const unverified = r.data.patterns.filter((p) => !verified.includes(p)); scans.push({ url, measured: facts, patterns: verified, unverified }); console.log(`\n${url}: ${verified.length} patterns${unverified.length ? ` (${unverified.length} set aside: quote not on the page)` : ""}`); for (const p of verified) console.log(` ${p.category}: ${p.name}. "${p.quote}"`); } const md = scans.flatMap((s) => [`## ${s.url}`, "", ...(s.patterns.length ? s.patterns.map((p) => `- **${p.category}**: ${p.name}. "${p.quote}". ${p.why}`) : ["Nothing found."]), ""]); mkdirSync(out, { recursive: true }); writeFileSync(join(out, "review.md"), `${done.result ?? ""}\n`); writeFileSync(join(out, "patterns.md"), `${md.join("\n")}\n`); writeFileSync( join(out, "result.json"), JSON.stringify({ task, status: done.status, review: done.result, pages, steps: done.steps.length, scans, agentRuns: [{ id: run.id }], model: done.model, usage: { modelUsd } }, null, 2), );} finally { await session.stop();}"""Dark pattern review, in two parts:1. An agent goes through a sign-up or checkout flow, stops before anything is bought or sent, and points out confusing or deceptive steps. The default is saucedemo.com, a demo shop made for testing.2. A page scan: each page the agent went through (or PAGES) is measured in the browser (boxes ticked in advance, countdowns that start again on reload, fees no earlier page showed, small print, pop-up wording), and a model sorts what it finds into categories. Every pattern quotes the page; a quote that is not on the page is set aside, not reported. python python/main.py (BOXLINE_API_KEY; TASK to review your own flow; PAGES, comma-separated, to scan)Writes output/review.md, output/patterns.md and output/result.json."""import jsonimport osimport reimport timefrom pathlib import Pathfrom urllib.parse import urlparsefrom boxline import Boxlinehere = Path(__file__).resolve().parentout = Path(os.environ.get("OUTPUT_DIR", "output"))task = os.environ.get( "TASK", "Open https://www.saucedemo.com, a demo shop made for testing, and sign in with the demo username and password shown on " "its page. Add one product to the cart and go through the checkout up to the last step before the order is placed, " "using the name Test User and the postcode 10115. Don't place the order. For each step, tell me if anything is " "confusing or deceptive: hidden costs, pre-ticked options, fake urgency, or a hard way back.",)pages_to_scan = [s.strip() for s in os.environ.get("PAGES", "").split(",") if s.strip()]scan_js = (here.parent / "scan.js").read_text()bx = Boxline()CATEGORIES = ["urgency", "scarcity", "sneaking", "hidden_costs", "confirmshaming", "forced_continuity", "obstruction", "misdirection", "forced_action"]PATTERNS = { "type": "object", "properties": { "patterns": { "type": "array", "items": { "type": "object", "properties": { "category": { "type": "string", "enum": CATEGORIES, "description": "urgency: time pressure (countdowns, 'ends tonight'); scarcity: low-stock or high-demand claims; sneaking: something added or ticked for the visitor in advance; hidden_costs: fees shown late or only in the total; confirmshaming: a 'no' worded to shame; forced_continuity: a trial or plan that renews and charges unless cancelled; obstruction: hard to cancel, leave or say no; misdirection: misleading buttons or design that hides the real choice; forced_action: a sign-up, share or other unrelated step required to go on", }, "name": {"type": "string", "description": "a few words, e.g. 'countdown that restarts'"}, "quote": {"type": "string", "description": "the exact words on the page that show it, copied as written"}, "why": {"type": "string", "description": "one sentence: why it misleads or pressures"}, }, "required": ["category", "name", "quote", "why"], }, } }, "required": ["patterns"],}def norm(s: str) -> str: return re.sub(r"[^a-z0-9$€£.%]+", " ", s.lower()).strip()def amounts(s: str) -> list: return [a.replace(",", "") for a in re.findall(r"\d[\d,]*\.\d{2}", s)]def seconds(clock: str) -> int: total = 0 for n in clock.split(":"): total = total * 60 + int(n) return totalwith bx.sessions.create(timeout=1200, user_metadata={"example": "dark-pattern-review"}) as session: print(f"Session: {session.id}", flush=True) # 1. The agent's walk through the flow, in this session (so the scan sees the same signed-in pages). run = bx.agent.run(task, session_id=session.id, max_steps=40) print(f"Agent run {run['id']} ({run['provider']}/{run['model']})", flush=True) for e in bx.agent.stream(run["id"]): if e["type"] == "tool": print(f"→ {e['name']} {json.dumps(e.get('input') or {})[:90]}", flush=True) elif e["type"] == "done": print(f"\n{e['status']}:\n{e.get('result') or e.get('error')}") done = bx.agent.get(run["id"]) model_usd = done["usage"]["costUsd"] or 0 # Every page the agent was on, from its tool calls and what they reported back. steps = json.dumps([[s.get("input"), s.get("output")] for s in done["steps"]]) pages = list(dict.fromkeys(u.rstrip(".,") for u in re.findall(r"https?://[^\s\"'\\)]+", steps))) # 2. The page scan: the pages given, else the agent's pages (never the one after an order is placed). host = urlparse(pages[0]).netloc if pages else "" targets = pages_to_scan or [u for u in pages if urlparse(u).netloc == host and not re.search(r"complete|confirm|thank", u, re.I)][-4:] scans = [] seen_amounts: set = set() # every amount on the pages scanned before this one for url in targets: session.goto(url) t0 = time.monotonic() first = session.evaluate(scan_js) if first["timers"]: # A real countdown keeps going while we wait; a fake one starts again when the page is loaded again. time.sleep(3) session.goto(url) elapsed = time.monotonic() - t0 again = session.evaluate(scan_js) for t, later in zip(first["timers"], again["timers"]): if re.search(r"\d:\d\d", t["text"]) and re.search(r"\d:\d\d", later["text"]): t["restartsOnReload"] = seconds(t["text"]) - seconds(later["text"]) < elapsed - 2 # A fee whose amount no earlier page showed was added late (drip pricing). if scans: first["lateCharges"] = [c for c in first["charges"] if any(a not in seen_amounts for a in amounts(c))] seen_amounts.update(amounts(first["text"])) facts = {k: v for k, v in first.items() if k != "text"} r = session.extract( "Find the dark patterns on this page: design that pushes or tricks a visitor (false urgency or scarcity, options ticked in advance, " "costs shown late, guilt-tripping wording on a 'no', renewals in small print, a hard way out, misleading buttons, forced sign-ups). " "Report only what the page clearly shows, each with the exact words from the page. These measurements were taken in the browser (lateCharges: fees that no earlier page of the flow showed): " + json.dumps(facts)[:4000], schema=PATTERNS, ) model_usd += r["usage"]["costUsd"] or 0 page = norm(first["text"]) verified = [p for p in r["data"]["patterns"] if p.get("quote") and norm(p["quote"]) in page] unverified = [p for p in r["data"]["patterns"] if p not in verified] scans.append({"url": url, "measured": facts, "patterns": verified, "unverified": unverified}) aside = f" ({len(unverified)} set aside: quote not on the page)" if unverified else "" print(f"\n{url}: {len(verified)} patterns{aside}") for p in verified: print(f" {p['category']}: {p['name']}. \"{p['quote']}\"")md = []for s in scans: md += [f"## {s['url']}", ""] + ([f"- **{p['category']}**: {p['name']}. \"{p['quote']}\". {p['why']}" for p in s["patterns"]] or ["Nothing found."]) + [""]out.mkdir(parents=True, exist_ok=True)(out / "review.md").write_text(f"{done['result'] or ''}\n")(out / "patterns.md").write_text("\n".join(md) + "\n")result = {"task": task, "status": done["status"], "review": done["result"], "pages": pages, "steps": len(done["steps"]), "scans": scans, "agentRuns": [{"id": run["id"]}], "model": done["model"], "usage": {"modelUsd": model_usd}}(out / "result.json").write_text(json.dumps(result, indent=2, ensure_ascii=False))// Runs in the page (session.evaluate): what a dark-pattern review measures rather than guesses. One expression;// returns plain JSON.(() => { const visible = (el) => { const r = el.getBoundingClientRect(); const st = getComputedStyle(el); return r.width > 0 && r.height > 0 && st.visibility !== "hidden" && st.display !== "none"; }; const text = (el) => (el.innerText || el.textContent || "").replace(/\s+/g, " ").trim(); const labelOf = (input) => text(input.closest("label") || document.querySelector(`label[for="${CSS.escape(input.id || "-")}"]`) || { innerText: input.getAttribute("aria-label") || input.name || "" }); // Ticked before the visitor did anything: the checked attribute in the page's own HTML. const preChecked = [...document.querySelectorAll('input[type="checkbox"], input[type="radio"]')] .filter((i) => i.defaultChecked && visible(i)) .slice(0, 30) .map((i) => ({ type: i.type, name: i.name, label: labelOf(i).slice(0, 200) })); // Countdowns: elements named timer/countdown, or whose whole text is a clock (mm:ss, hh:mm:ss). const timers = [...document.querySelectorAll("body *")] .filter((el) => el.children.length === 0 && visible(el)) .filter((el) => /timer|countdown|clock/i.test(`${el.id} ${el.className}`) || /^\d{1,2}:\d{2}(:\d{2})?$/.test(text(el))) .slice(0, 10) .map((el) => ({ id: el.id || null, text: text(el), context: text(el.parentElement || el).slice(0, 160) })); // Small or faint print that talks about money, renewals or cancelling. const smallPrint = [...document.querySelectorAll("p, span, small, div, li, td, label")] .filter((el) => visible(el) && [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())) .map((el) => ({ el, st: getComputedStyle(el) })) .filter(({ el, st }) => parseFloat(st.fontSize) < 11 && /[$€£]|\d+%|renew|month|year|cancel|trial|fee|subscription/i.test(text(el))) .slice(0, 20) .map(({ el, st }) => ({ text: text(el).slice(0, 300), fontSize: parseFloat(st.fontSize), color: st.color })); // Pop-ups and their choices (the wording of a "no" is where confirmshaming lives). const dialogs = [...document.querySelectorAll('[role="dialog"], [role="alertdialog"], dialog, .modal, .popup')] .filter(visible) .slice(0, 10) .map((d) => ({ text: text(d).slice(0, 400), choices: [...d.querySelectorAll("button, a, [role=button]")].map(text).filter(Boolean).slice(0, 8) })); // Lines that name a fee or charge with its amount (the example compares them with the pages before). const charges = [ ...new Set( [...document.querySelectorAll("tr, li, p, dd, dt, span, div")] .filter((el) => visible(el) && el.querySelectorAll("tr, li, p, div").length === 0) .map(text) .filter((t) => t.length < 160 && /(fee|charge|surcharge|service|handling|processing|booking|convenience)[^$€£]{0,40}[$€£]\s?\d/i.test(t)), ), ].slice(0, 20); const body = text(document.body); return { url: location.href, title: document.title, text: body.slice(0, 8000), preChecked, timers, smallPrint, dialogs, charges, prices: [...new Set(body.match(/[$€£]\s?\d[\d,]*(\.\d{2})?(\s?\/\s?(month|mo|year|yr))?/g) || [])].slice(0, 40), };})()Inputs
Environment variables the example reads:
TASK: review your own flow (say where to stop).PAGES: pages to scan, comma-separated (default: the pages the agent went through, except one after an order).
Run it
Get the examples with git clone https://github.com/Boxline-dev/examples.git and open this one: cd examples/dark-pattern-review. 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.
npx tsx check.ts output reads what the example wrote and exits with 0 only when the result is right:
Source
github.com/Boxline-dev/examples/dark-pattern-review 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.