From cc1c88e093d6dcaa40809788f1974af316ea0f0d Mon Sep 17 00:00:00 2001 From: Kenzie Macdonald Date: Mon, 4 May 2026 17:06:15 -0700 Subject: [PATCH 1/3] adding --- typescript/competitor-monitoring/index.ts | 207 ++++++++++++++++++ typescript/competitor-monitoring/package.json | 22 ++ 2 files changed, 229 insertions(+) create mode 100644 typescript/competitor-monitoring/index.ts create mode 100644 typescript/competitor-monitoring/package.json diff --git a/typescript/competitor-monitoring/index.ts b/typescript/competitor-monitoring/index.ts new file mode 100644 index 00000000..95ab0caa --- /dev/null +++ b/typescript/competitor-monitoring/index.ts @@ -0,0 +1,207 @@ +import "dotenv/config"; +import fs from "fs"; +import path from "path"; +import { URL } from "url"; +import Browserbase from "@browserbasehq/sdk"; +import { chromium } from "playwright-core"; +import Anthropic from "@anthropic-ai/sdk"; + +const SCREENSHOTS_DIR = "./screenshots"; +const COMPARISON_FILE = "./comparison.md"; + +interface Target { + name: string; + url: string; +} + +interface PricingPlan { + name: string; + price: string; + features: string[]; +} + +interface CompetitorData { + name: string; + plans: PricingPlan[]; +} + +interface AnalysisResult { + competitors: CompetitorData[]; +} + +const DEFAULT_COMPETITORS: Target[] = [ + { name: "Asana", url: "https://asana.com/pricing" }, + { name: "Linear", url: "https://linear.app/pricing" }, + { name: "Notion", url: "https://www.notion.com/pricing" }, +]; + +function nameFromUrl(rawUrl: string): string { + const hostname = new URL(rawUrl).hostname.replace(/^www\./, ""); + return hostname.split(".")[0] ?? hostname; +} + +function resolveTargets(): Target[] { + const args = process.argv.slice(2).filter((a) => a.startsWith("http")); + if (args.length === 0) return DEFAULT_COMPETITORS; + return args.map((u) => ({ name: nameFromUrl(u), url: u })); +} + +async function main(): Promise { + console.log("=== Competitor Pricing Monitor ===\n"); + + const targets = resolveTargets(); + const usingDefaults = targets === DEFAULT_COMPETITORS; + console.log( + usingDefaults + ? `Using default competitors: ${targets.map((t) => t.name).join(", ")}` + : `Custom URLs provided: ${targets.map((t) => t.url).join(", ")}`, + ); + console.log(); + + fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true }); + + const bb = new Browserbase({ apiKey: process.env.BROWSERBASE_API_KEY }); + + console.log("Creating Browserbase remote browser session..."); + const session = await bb.sessions.create({ + projectId: process.env.BROWSERBASE_PROJECT_ID!, + }); + console.log(`Session created: ${session.id}\n`); + + const connectUrl = `wss://connect.browserbase.com?apiKey=${process.env.BROWSERBASE_API_KEY}&sessionId=${session.id}`; + const browser = await chromium.connectOverCDP(connectUrl); + console.log("Playwright connected to remote browser via CDP.\n"); + + const screenshots: Array<{ name: string; base64: string }> = []; + const savedFiles: string[] = []; + + for (const target of targets) { + console.log(`[${target.name}] Navigating to ${target.url} ...`); + const page = await browser.newPage(); + + await page.goto(target.url, { waitUntil: "domcontentloaded", timeout: 30_000 }); + + await page + .waitForSelector('main, [role="main"], #main, .pricing', { timeout: 10_000 }) + .catch(() => { + console.log(` [${target.name}] Main selector not found — continuing anyway.`); + }); + + await page.waitForTimeout(2000); + + console.log(` [${target.name}] Page loaded. Taking screenshot...`); + const buffer = await page.screenshot({ type: "png", fullPage: true }); + const base64 = buffer.toString("base64"); + + const filename = `${target.name.toLowerCase()}.png`; + const filepath = path.join(SCREENSHOTS_DIR, filename); + fs.writeFileSync(filepath, buffer); + savedFiles.push(filepath); + + screenshots.push({ name: target.name, base64 }); + console.log( + ` [${target.name}] Screenshot saved → ${filepath} (${Math.round(buffer.length / 1024)} KB)\n`, + ); + + await page.close(); + } + + console.log("All screenshots captured. Closing browser session..."); + await browser.close(); + console.log("Browser session closed.\n"); + + const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); + + const imageBlocks = screenshots.map((s) => ({ + type: "image" as const, + source: { type: "base64" as const, media_type: "image/png" as const, data: s.base64 }, + })); + + const labelBlock = { + type: "text" as const, + text: + `The screenshots below are (in order): ${screenshots.map((s) => s.name).join(", ")}.\n\n` + + "You are a pricing analyst. Here are screenshots of competitor pricing pages. " + + "Extract the plan names, prices, and key features for each competitor. " + + "Return ONLY valid JSON matching this exact structure — no markdown fences, no extra text:\n" + + "{\n" + + ' "competitors": [\n' + + " {\n" + + ' "name": "CompanyName",\n' + + ' "plans": [\n' + + ' { "name": "Plan Name", "price": "$X/mo", "features": ["feature1", "feature2"] }\n' + + " ]\n" + + " }\n" + + " ]\n" + + "}", + }; + + console.log(`Sending ${screenshots.length} screenshot(s) to Claude for analysis...`); + + const response = await anthropic.messages.create({ + model: "claude-opus-4-7", + max_tokens: 4096, + messages: [{ role: "user", content: [labelBlock, ...imageBlocks] }], + }); + + const rawText = response.content + .filter((b) => b.type === "text") + .map((b) => (b as { type: "text"; text: string }).text) + .join("\n") + .replace(/^```(?:json)?\s*/i, "") + .replace(/\s*```$/, "") + .trim(); + + let data: AnalysisResult | null = null; + try { + data = JSON.parse(rawText) as AnalysisResult; + } catch { + console.error("Could not parse Claude response as JSON — printing raw output:\n"); + console.log(rawText); + } + + console.log("Claude analysis complete.\n"); + console.log("=== Pricing Comparison ==="); + + const mdLines = [`# Competitor Pricing Comparison\n\n_Generated: ${new Date().toISOString()}_\n`]; + + if (data?.competitors) { + const COL_PLAN = 22; + const COL_PRICE = 16; + const DIVIDER = "─".repeat(COL_PLAN + COL_PRICE + 40); + const pad = (str: unknown, len: number): string => String(str ?? "").padEnd(len); + + for (const competitor of data.competitors) { + console.log(`\n${competitor.name.toUpperCase()}`); + console.log(DIVIDER); + console.log(`${pad("PLAN", COL_PLAN)}${pad("PRICE", COL_PRICE)}KEY FEATURES`); + console.log(DIVIDER); + + const mdTableRows = ["| Plan | Price | Key Features |", "|------|-------|--------------|"]; + + for (const plan of competitor.plans ?? []) { + const features = (plan.features ?? []).join(", "); + console.log(`${pad(plan.name, COL_PLAN)}${pad(plan.price, COL_PRICE)}${features}`); + mdTableRows.push(`| ${plan.name} | ${plan.price} | ${features} |`); + } + + console.log(DIVIDER); + mdLines.push(`## ${competitor.name}\n\n${mdTableRows.join("\n")}\n`); + } + } else { + mdLines.push(rawText); + } + + fs.writeFileSync(COMPARISON_FILE, mdLines.join("\n")); + + console.log("\n=== Summary ==="); + console.log(`Pages visited: ${targets.length} (${targets.map((t) => t.name).join(", ")})`); + console.log(`Session ID: ${session.id}`); + console.log(`Screenshots: ${savedFiles.join(", ")}`); + console.log(`Comparison saved: ${COMPARISON_FILE}`); +} + +main().catch((err: unknown) => { + console.error("Fatal error:", err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/typescript/competitor-monitoring/package.json b/typescript/competitor-monitoring/package.json new file mode 100644 index 00000000..ea556a50 --- /dev/null +++ b/typescript/competitor-monitoring/package.json @@ -0,0 +1,22 @@ +{ + "name": "competitor-monitoring", + "version": "1.0.0", + "description": "Playwright + Browserbase + Claude: Competitor Pricing Monitor", + "type": "module", + "main": "index.ts", + "scripts": { + "start": "tsx index.ts", + "dev": "tsx watch index.ts" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.54.0", + "@browserbasehq/sdk": "^2.9.0", + "dotenv": "^16.4.5", + "playwright-core": "^1.49.0" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "tsx": "^4.16.0", + "typescript": "^5.5.0" + } +} From 18db625e566c1c8ff62e43c3b8ac4b4a2374e019 Mon Sep 17 00:00:00 2001 From: Kenzie Macdonald Date: Mon, 11 May 2026 11:13:09 -0700 Subject: [PATCH 2/3] script --- typescript/competitor-monitoring/.env.example | 8 +++ typescript/competitor-monitoring/README.md | 69 +++++++++++++++++++ typescript/competitor-monitoring/index.ts | 56 +++++++-------- 3 files changed, 106 insertions(+), 27 deletions(-) create mode 100644 typescript/competitor-monitoring/.env.example create mode 100644 typescript/competitor-monitoring/README.md diff --git a/typescript/competitor-monitoring/.env.example b/typescript/competitor-monitoring/.env.example new file mode 100644 index 00000000..77b276c6 --- /dev/null +++ b/typescript/competitor-monitoring/.env.example @@ -0,0 +1,8 @@ +# Browserbase Configuration +# Get your API key from: https://www.browserbase.com/settings +BROWSERBASE_API_KEY=your_browserbase_api_key +BROWSERBASE_PROJECT_ID=your_browserbase_project_id + +# AI Model Configuration +# Used for pricing page analysis via Claude +MODEL_API_KEY=your_anthropic_api_key diff --git a/typescript/competitor-monitoring/README.md b/typescript/competitor-monitoring/README.md new file mode 100644 index 00000000..051d25b5 --- /dev/null +++ b/typescript/competitor-monitoring/README.md @@ -0,0 +1,69 @@ +# Playwright + Browserbase + Claude: Competitor Pricing Monitor + +## AT A GLANCE + +- Goal: visit competitor pricing pages, take full-page screenshots, and extract structured pricing data using Claude vision. +- Browser Automation: uses Playwright + Browserbase to navigate pricing pages in a remote browser. +- AI Analysis: sends screenshots to Claude claude-opus-4-7 which extracts plan names, prices, and key features as JSON. +- Flexible Targets: defaults to Asana, Linear, and Notion; pass any URLs as CLI arguments to override. + Docs → https://docs.browserbase.com + +## GLOSSARY + +- Browserbase: cloud browser infrastructure for reliable, scalable web automation + Docs → https://docs.browserbase.com +- CDP: Chrome DevTools Protocol — used to connect Playwright to a remote Browserbase session +- Claude Vision: multimodal Claude API that can analyze screenshots and extract structured data + +## QUICKSTART + +1. cd typescript/competitor-monitoring +2. npm install +3. cp .env.example .env +4. Add BROWSERBASE_API_KEY, BROWSERBASE_PROJECT_ID, and MODEL_API_KEY to .env +5. npm start + +To monitor custom URLs instead of the defaults: + +``` +npm start -- https://example.com/pricing https://other.com/pricing +``` + +## EXPECTED OUTPUT + +- Creates a remote Browserbase browser session +- Visits each competitor pricing page and takes a full-page screenshot +- Saves screenshots to ./screenshots/ +- Sends all screenshots to Claude for analysis +- Prints a formatted pricing comparison table to the console +- Saves the comparison as ./comparison.md +- Closes the browser session + +## COMMON PITFALLS + +- "Cannot find module": ensure npm install completed +- Missing credentials: verify .env contains BROWSERBASE_API_KEY, BROWSERBASE_PROJECT_ID, and MODEL_API_KEY +- JSON parse error: Claude occasionally returns markdown-wrapped JSON; the template strips fences automatically +- Pricing page layout changes: Claude's extraction is prompt-based and may need updates if a site restructures its pricing page + +## USE CASES + +• Track competitor pricing changes over time by scheduling periodic runs and diffing the output. +• Onboard new competitors quickly by passing their pricing URLs as CLI arguments. +• Feed extracted pricing data into a dashboard or alerting system via the JSON output. + +## NEXT STEPS + +• Schedule runs with a cron job and diff comparison.md against the previous version to detect price changes. +• Add more competitors by extending DEFAULT_COMPETITORS in index.ts or passing additional URLs at runtime. +• Store results in a database to build a pricing history timeline. + +## HELPFUL RESOURCES + +📚 Browserbase Docs: https://docs.browserbase.com +🤖 Claude API: https://docs.anthropic.com +🎮 Browserbase: https://www.browserbase.com +💡 Try it out: https://www.browserbase.com/playground +🔧 Templates: https://www.browserbase.com/templates +📧 Need help? support@browserbase.com +💬 Discord: http://stagehand.dev/discord diff --git a/typescript/competitor-monitoring/index.ts b/typescript/competitor-monitoring/index.ts index 95ab0caa..8b3fde43 100644 --- a/typescript/competitor-monitoring/index.ts +++ b/typescript/competitor-monitoring/index.ts @@ -75,42 +75,44 @@ async function main(): Promise { const screenshots: Array<{ name: string; base64: string }> = []; const savedFiles: string[] = []; - for (const target of targets) { - console.log(`[${target.name}] Navigating to ${target.url} ...`); - const page = await browser.newPage(); + try { + for (const target of targets) { + console.log(`[${target.name}] Navigating to ${target.url} ...`); + const page = await browser.newPage(); - await page.goto(target.url, { waitUntil: "domcontentloaded", timeout: 30_000 }); + await page.goto(target.url, { waitUntil: "domcontentloaded", timeout: 30_000 }); - await page - .waitForSelector('main, [role="main"], #main, .pricing', { timeout: 10_000 }) - .catch(() => { - console.log(` [${target.name}] Main selector not found — continuing anyway.`); - }); + await page + .waitForSelector('main, [role="main"], #main, .pricing', { timeout: 10_000 }) + .catch(() => { + console.log(` [${target.name}] Main selector not found — continuing anyway.`); + }); - await page.waitForTimeout(2000); + await page.waitForTimeout(2000); - console.log(` [${target.name}] Page loaded. Taking screenshot...`); - const buffer = await page.screenshot({ type: "png", fullPage: true }); - const base64 = buffer.toString("base64"); + console.log(` [${target.name}] Page loaded. Taking screenshot...`); + const buffer = await page.screenshot({ type: "png", fullPage: true }); + const base64 = buffer.toString("base64"); - const filename = `${target.name.toLowerCase()}.png`; - const filepath = path.join(SCREENSHOTS_DIR, filename); - fs.writeFileSync(filepath, buffer); - savedFiles.push(filepath); + const filename = `${target.name.toLowerCase()}.png`; + const filepath = path.join(SCREENSHOTS_DIR, filename); + fs.writeFileSync(filepath, buffer); + savedFiles.push(filepath); - screenshots.push({ name: target.name, base64 }); - console.log( - ` [${target.name}] Screenshot saved → ${filepath} (${Math.round(buffer.length / 1024)} KB)\n`, - ); + screenshots.push({ name: target.name, base64 }); + console.log( + ` [${target.name}] Screenshot saved → ${filepath} (${Math.round(buffer.length / 1024)} KB)\n`, + ); - await page.close(); + await page.close(); + } + } finally { + console.log("Closing browser session..."); + await browser.close(); + console.log("Browser session closed.\n"); } - console.log("All screenshots captured. Closing browser session..."); - await browser.close(); - console.log("Browser session closed.\n"); - - const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); + const anthropic = new Anthropic({ apiKey: process.env.MODEL_API_KEY }); const imageBlocks = screenshots.map((s) => ({ type: "image" as const, From 92a84b65e1ad898cd20265ce63bb0ae0da94507e Mon Sep 17 00:00:00 2001 From: Kenzie Macdonald Date: Mon, 11 May 2026 11:30:55 -0700 Subject: [PATCH 3/3] Add competitor-monitoring to README template index --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 51ff915f..7b33fea1 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Ready-to-use automation templates for Stagehand and Browserbase. Each template h | cerebras-docs-checker | - | [PY](python/cerebras-docs-checker) | - | Crawl documentation sites, discover source repos, and verify docs accuracy against actual codebase | | company-address-finder | [TS](typescript/company-address-finder) | [PY](python/company-address-finder) | - | Discover company legal information and physical addresses from Terms of Service and Privacy Policy pages | | company-value-prop-generator | [TS](typescript/company-value-prop-generator) | [PY](python/company-value-prop-generator) | - | Extract and format website value propositions into concise one-liners for email personalization | +| competitor-monitoring | [TS](typescript/competitor-monitoring) | - | - | Visit competitor pricing pages, take full-page screenshots, and extract structured pricing data using Claude | | context | [TS](typescript/context) | [PY](python/context) | - | Persistent authentication using Browserbase contexts that survive across sessions | | council-events | [TS](typescript/council-events) | [PY](python/council-events) | - | Automate event information extraction from Philadelphia Council | | download-financial-statements | [TS](typescript/download-financial-statements) | [PY](python/download-financial-statements) | - | Download Apple's quarterly financial statements (PDFs) from their investor relations site |